1
0
forked from BRT/arc

Optimize "GetLastTransactionsScript" with single-pass search for all targets, add geometric fallback for bottom navigation taps, and improve error handling for unmatched transactions.

This commit is contained in:
slava 2026-06-02 20:06:57 +05:00
parent fb9fa279a6
commit b486e922b3
3 changed files with 144 additions and 154 deletions

View File

@ -223,15 +223,9 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
m_finished = true;
if (m_folder.isEmpty()) return;
// "Interrupted" трактуем как НЕ-ошибку для дампа: это следствие внешнего
// requestInterruption() (watchdog-таймаут или clearAll при остановке/выходе),
// а не самостоятельный сбой. Причину уже репортит инициатор прерывания
// (watchdog шлёт "Script timeout (N min)"), поэтому тяжёлый ZIP-дамп по
// Interrupted не шлём — иначе на один таймаут копится дубль log+dump.
if (errorMessage.isEmpty() || errorMessage == "Interrupted") {
if (errorMessage.isEmpty()) {
QDir(m_folder).removeRecursively();
qDebug() << "[TaskDumpRecorder]" << (errorMessage.isEmpty() ? "success" : "interrupted")
<< "— removed" << m_folder;
qDebug() << "[TaskDumpRecorder] success — removed" << m_folder;
return;
}

View File

@ -1,6 +1,7 @@
#include "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QHash>
#include <QRegularExpression>
#include <QSet>
#include <QThread>
@ -303,9 +304,9 @@ void GetLastTransactionsScript::doStart() {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
// Тапаем "История" в нижнем меню
Node historyBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История"));
if (historyBtn.x() == 0 && historyBtn.y() == 0) {
// Тапаем "История" в нижнем меню (с геометрическим fallback при [0,0][0,0]).
const QPoint historyPt = bottomNavPoint(QString::fromUtf8("История"), device);
if (historyPt.x() < 0) {
m_error = "Cannot find History button: " + m_deviceId;
qWarning() << m_error;
AdbUtils::tryToKillApplication(m_deviceId, app.package);
@ -313,7 +314,7 @@ void GetLastTransactionsScript::doStart() {
return;
}
AdbUtils::makeTap(m_deviceId, historyBtn.x(), historyBtn.y());
AdbUtils::makeTap(m_deviceId, historyPt.x(), historyPt.y());
QThread::msleep(3000);
// Ждём экран истории
@ -397,45 +398,35 @@ void GetLastTransactionsScript::doStart() {
// История отсортирована сверху-вниз: новые операции выше старых. Ищем цели
// в том же порядке (по времени, от новых к старым) — тогда один непрерывный
// проход вниз находит все, и возвращаться к началу между целями не нужно.
QList<QPair<double, QDateTime>> targets = m_targets;
std::sort(targets.begin(), targets.end(),
// Single-pass: один проход вниз обслуживает ВСЕ цели сразу. Список Выписки
// идёт сверху-вниз (новые→старые), сортируем цели так же и отдаём в searchAll.
// Найденные он эмитит через transactionParsed и убирает из pending; в остатке —
// ненайденные, по которым собираем агрегированную ошибку.
QList<QPair<double, QDateTime>> pending = m_targets;
std::sort(pending.begin(), pending.end(),
[](const QPair<double, QDateTime> &a, const QPair<double, QDateTime> &b) {
return a.second > b.second; // по убыванию времени (новые первыми)
});
// Ищем каждую цель из списка. Найденные отдаём сразу через transactionParsed;
// ненайденные копим и репортим одной агрегированной ошибкой в конце.
QStringList notFound;
for (int i = 0; i < targets.size(); ++i) {
if (QThread::currentThread()->isInterruptionRequested()) { m_error = "Interrupted"; break; }
const double amount = targets[i].first;
const QDateTime time = targets[i].second;
// Проход идёт вниз от текущей позиции. Если цель не нашлась — возможно,
// предыдущая (более новая) цель отсутствовала и увела список вниз, а эта
// лежит выше. Тогда надёжно возвращаемся к началу Выписки и пробуем ещё раз.
SearchResult res = searchOne(device, accountId, amount, time);
if (res == SearchResult::NotFound && m_error != "Interrupted") {
scrollHistoryToTop(device);
res = searchOne(device, accountId, amount, time);
}
if (res != SearchResult::Found) {
if (m_error == "Interrupted") break;
notFound << QString("amount=%1 time=%2")
.arg(QString::number(amount, 'f', 2), time.toString(Qt::ISODate));
}
}
searchAll(device, accountId, pending);
if (m_error != "Interrupted") {
QStringList notFound;
for (const auto &t : pending) {
notFound << QString("amount=%1 time=%2")
.arg(QString::number(t.first, 'f', 2), t.second.toString(Qt::ISODate));
}
m_error = notFound.isEmpty()
? QString()
: QString("Transactions not found (%1/%2): %3")
.arg(notFound.size()).arg(m_targets.size()).arg(notFound.join("; "));
}
// Возвращаемся на главную
// Возвращаемся на главную (с геометрическим fallback при [0,0][0,0]).
{
Node glavnayaBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Главная"));
if (glavnayaBtn.x() > 0 && glavnayaBtn.y() > 0) {
AdbUtils::makeTap(m_deviceId, glavnayaBtn.x(), glavnayaBtn.y());
const QPoint homePt = bottomNavPoint(QString::fromUtf8("Главная"), device);
if (homePt.x() >= 0) {
AdbUtils::makeTap(m_deviceId, homePt.x(), homePt.y());
} else {
AdbUtils::goBack(m_deviceId);
}
@ -445,59 +436,44 @@ void GetLastTransactionsScript::doStart() {
emit finishedWithResult(m_error);
}
void GetLastTransactionsScript::scrollHistoryToTop(const DeviceInfo &device) {
// Свайпы сверху-вниз поднимают список Выписки к началу. Поиск ОДНОЙ цели
// (searchOne) мог проскроллить вниз до maxScrolls(=10) раз, поэтому
// фиксированного числа свайпов вверх не хватает — крутим, пока верх списка
// не перестанет меняться (значит достигли начала), с запасом по итерациям.
static const QRegularExpression reTime(R"(\d{2}:\d{2}:\d{2})");
QPoint GetLastTransactionsScript::bottomNavPoint(const QString &label, const DeviceInfo &device) {
const Node n = xmlScreenParser.findNodeByContentDesc(label);
// Реальные bounds принимаем только если они «вменяемые» для НИЖНЕГО меню:
// непустой узел, ненулевой центр И центр в нижней полосе экрана (>= 80%
// высоты). Это отсекает не только вырожденные [0,0][0,0] (Infinix XOS), но и
// НЕнулевые, но негодные координаты — напр. одноимённый элемент не в нав-баре
// или частичные bounds. Иначе нав-бар у самого низа, центр таба ~95% высоты.
if (!n.isEmpty() && n.x() > 0 && n.y() >= static_cast<int>(device.screenHeight * 0.80))
return {n.x(), n.y()};
// Сигнатура верха списка: content-desc самой верхней View-строки операции
// (last line — время HH:MM:SS). Пустая строка — список не распознан/пуст.
auto topSignature = [&]() -> QString {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
QString best;
int bestY = device.screenHeight + 1;
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;
if (!reTime.match(lines.last().trimmed()).hasMatch()) continue;
if (n.y1() < bestY) { bestY = n.y1(); best = n.contentDesc; }
}
return best;
// Узел нав-таба отсутствует или негоден. На части устройств
// (наблюдалось на Infinix X669D, app 3.2.41 — гибридный UI) uiautomator не
// отдаёт bounds нижнего меню, хотя оно отрисовано в полосе у самого низа
// экрана (ниже всего дерева доступности). Тапаем по геометрии: 5 равных
// слотов — Главная · Переводы · [FAB] · MiniApps · История; центр слота —
// доля ширины, высота — у самого низа экрана.
static const QHash<QString, double> kFx{
{QString::fromUtf8("Главная"), 0.10},
{QString::fromUtf8("Переводы"), 0.30},
{QString::fromUtf8("MiniApps"), 0.70},
{QString::fromUtf8("История"), 0.90},
};
const int x = device.screenWidth / 2;
const int fromY = device.screenHeight / 3; // ниже шапки — без pull-to-refresh
const int toY = device.screenHeight * 3 / 4; // длинный бросок вниз → контент вверх
const int maxUpSwipes = 14; // > maxScrolls(10) с запасом
QString prevTop = topSignature();
int stable = 0;
for (int i = 0; i < maxUpSwipes; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return;
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(700);
const QString curTop = topSignature();
if (!curTop.isEmpty() && curTop == prevTop) {
if (++stable >= 2) break; // верх не меняется два свайпа подряд → начало списка
} else {
stable = 0;
prevTop = curTop;
}
}
QThread::msleep(500);
const auto it = kFx.constFind(label);
if (it == kFx.constEnd()) return {-1, -1};
const int x = static_cast<int>(device.screenWidth * it.value());
const int y = static_cast<int>(device.screenHeight * 0.95);
qWarning() << "[Dushanbe::GetLastTransactions] nav tab" << label
<< "has no usable bounds — geometric fallback tap at" << x << y;
return {x, y};
}
GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
void GetLastTransactionsScript::searchAll(
const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime) {
QList<QPair<double, QDateTime>> &pending) {
// Скриншот списка транзакций — отправим при неудаче
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
qDebug() << "[Dushanbe::GetLastTransactions] Searching: amount=" << searchAmount
<< "time=" << searchTime.toString(Qt::ISODate);
qDebug() << "[Dushanbe::GetLastTransactions] searchAll: targets=" << pending.size();
// Время в банковском приложении показывается в таймзоне Душанбе (UTC+5), не устройства
const QTimeZone tjTz("Asia/Dushanbe");
@ -527,10 +503,10 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
QString prevViewSig;
int stallScrolls = 0;
for (int scroll = 0; scroll <= maxScrolls; ++scroll) {
for (int scroll = 0; scroll <= maxScrolls && !pending.isEmpty(); ) {
if (interrupted()) {
m_error = "Interrupted";
return SearchResult::NotFound;
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
@ -719,49 +695,56 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
<< "y=" << items[i].node.y();
}
const double searchAbs = qAbs(searchAmount);
// Single-pass: сверяем каждую видимую транзакцию со ВСЕМ набором
// оставшихся целей. Найденную убираем из pending и пере-сканируем тот же
// экран (matchedThisScreen), скроллим только когда matchей на экране нет.
bool matchedThisScreen = false;
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;
// Выбираем подходящую цель: совпадение по сумме + пред-фильтры даты/времени.
int matchIdx = -1;
bool matchedX100 = false;
for (int ti = 0; ti < pending.size(); ++ti) {
const double searchAbs = qAbs(pending[ti].first);
const QDateTime &cand = pending[ti].second;
const bool amountDirect = qAbs(absListAmt - searchAbs) < 0.01;
const bool amountX100 = qAbs(absListAmt * 100.0 - searchAbs) < 0.01;
if (!amountDirect && !amountX100) continue;
// Pre-filter по ДАТЕ: только если дата надёжная (из content-desc P2P формата).
// DCWallet транзакции получают дату из groupDates ImageView, которая
// неправильно присваивается при скролле — не фильтруем по ней.
if (tx.dateReliable && cand.isValid() && !tx.date.isEmpty()) {
const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy");
const QDate searchLocalDate = cand.toTimeZone(tjTz).date();
if (listDate.isValid() && searchLocalDate.isValid()) {
const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate));
if (dayDiff > 3) continue;
}
}
// Проверка времени по HH:MM:SS (±10 минут, с учётом wraparound полуночи)
if (cand.isValid() && !tx.time.isEmpty()) {
const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss");
const QTime searchLocalTime = cand.toTimeZone(tjTz).time();
if (listTime.isValid() && searchLocalTime.isValid()) {
const int diffSecs = qAbs(listTime.secsTo(searchLocalTime));
if (diffSecs > 600 && diffSecs < 85800) continue; // 85800 = 23h50m
}
}
matchIdx = ti;
matchedX100 = amountX100 && !amountDirect;
break;
}
if (matchIdx < 0) continue;
const QDateTime searchTime = pending[matchIdx].second;
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;
}
}
}
<< "date:" << tx.date << "time:" << tx.time
<< "→ target idx" << matchIdx;
Node txNode = tx.node;
@ -782,13 +765,13 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
const int tapX = txNode.x1() + 80;
AdbUtils::makeTap(m_deviceId, tapX, txNode.y());
QThread::msleep(3000);
if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; }
if (interrupted()) { m_error = "Interrupted"; return; }
// Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж"
OcrTransactionDetail detail;
bool detailLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; }
if (interrupted()) { m_error = "Interrupted"; return; }
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):
@ -994,9 +977,18 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
}
QThread::msleep(1000);
return SearchResult::Found;
// Цель найдена и отдана — убираем из набора и пере-сканируем этот же
// экран (вдруг на нём есть ещё цели), прежде чем скроллить дальше.
pending.removeAt(matchIdx);
matchedThisScreen = true;
break;
}
if (pending.isEmpty()) break;
// На экране что-то нашли — пере-сканируем его же (без скролла): другие
// цели могут быть на тех же позициях, а скролл их бы пропустил.
if (matchedThisScreen) continue;
// Окно поиска — "сегодня + (searchWindowDays-1) предыдущих дней". Список
// Выписки идёт сверху-вниз от новых к старым; группы-даты на текущем экране
// собраны в groupDates. Как только самая свежая видимая дата уже старше
@ -1016,7 +1008,7 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
" (newest visible=" << newestVisible.toString("dd.MM.yyyy")
<< "< cutoff=" << cutoff.toString("dd.MM.yyyy")
<< ") — stopping scroll";
return SearchResult::PastWindow;
return; // дальше только старая история — оставшиеся цели не найдены
}
}
@ -1031,8 +1023,8 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
if (!prevViewSig.isEmpty() && viewSig == prevViewSig) {
if (++stallScrolls >= 2) {
qWarning() << "[Dushanbe::GetLastTransactions] No scroll progress for"
<< stallScrolls << "swipes (list stuck) — aborting search for amount="
<< QString::number(searchAmount, 'f', 2);
<< stallScrolls << "swipes (list stuck) — aborting,"
<< pending.size() << "target(s) still not found";
break;
}
} else {
@ -1043,7 +1035,7 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
// Скроллим вниз — свайп внутри области транзакций
if (scroll < maxScrolls) {
if (interrupted()) { m_error = "Interrupted"; return SearchResult::NotFound; }
if (interrupted()) { m_error = "Interrupted"; return; }
const int x = device.screenWidth / 2;
// Определяем область контента из реальных Y позиций items
int contentBottom = device.screenHeight * 2 / 3;
@ -1058,20 +1050,19 @@ GetLastTransactionsScript::SearchResult GetLastTransactionsScript::searchOne(
AdbUtils::makeSwipe(m_deviceId, x, fromY, x, toY, 500);
QThread::msleep(2000);
}
++scroll; // успешный match делает continue выше и сюда не доходит
}
// Эту цель не нашли. 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 SearchResult::NotFound;
// Прошли весь доступный диапазон/окно. В pending остались ненайденные цели —
// агрегированную ошибку по ним соберёт doStart (он же вернёт на главную).
if (!pending.isEmpty()) {
qWarning() << "[Dushanbe::GetLastTransactions] searchAll finished,"
<< pending.size() << "target(s) not found";
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
}
}
} // namespace Dushanbe

View File

@ -4,6 +4,7 @@
#include <QJsonObject>
#include <QList>
#include <QPair>
#include <QPoint>
#include "db/DeviceInfo.h"
#include "db/BankProfileInfo.h"
@ -33,18 +34,22 @@ protected:
void doStart() override;
private:
enum class SearchResult { Found, NotFound, PastWindow };
// Single-pass поиск ВСЕХ целей за один проход вниз по "Выписке": на каждом
// экране сверяем видимые транзакции со всем набором оставшихся целей,
// найденные эмитим через transactionParsed и убираем из pending, затем
// пере-сканируем тот же экран. Это убирает N-кратный перескролл (раньше
// каждая цель искалась отдельным searchOne с возвратом к началу списка → при
// многих целях упор в 10-мин watchdog). По завершении в pending остаются
// только ненайденные цели. Прерывание выставляет m_error="Interrupted".
void searchAll(const DeviceInfo &device, int accountId,
QList<QPair<double, QDateTime>> &pending);
// Поиск одной цели по сумме и времени на экране "Выписка".
// Found — нашли, transactionParsed эмитирован.
// NotFound — не нашли, список прокручен до maxScrolls.
// PastWindow — список старше окна today+yesterday, retry бессмысленен.
// Прерывание выставляет m_error="Interrupted" и возвращает NotFound.
SearchResult searchOne(const DeviceInfo &device, int accountId,
double searchAmount, const QDateTime &searchTime);
// Возврат к началу списка "Выписка" между поисками целей.
void scrollHistoryToTop(const DeviceInfo &device);
// Координаты тапа по табу НИЖНЕГО меню. Сначала пробуем реальные bounds из
// дерева доступности; если узел отсутствует или вырожден ([0,0][0,0] —
// гибридный UI на части устройств, напр. Infinix XOS, не отдаёт bounds
// нижнего бара, хотя он отрисован у самого низа экрана), падаем на геометрию
// (доли ширины/высоты). Возвращает {-1,-1}, если таб неизвестен.
QPoint bottomNavPoint(const QString &label, const DeviceInfo &device);
QString m_deviceId;
const QString m_appCode;