From b486e922b36915cf4819753e7e2a2544203b204f Mon Sep 17 00:00:00 2001 From: slava Date: Tue, 2 Jun 2026 20:06:57 +0500 Subject: [PATCH] Optimize "GetLastTransactionsScript" with single-pass search for all targets, add geometric fallback for bottom navigation taps, and improve error handling for unmatched transactions. --- android/dump/TaskDumpRecorder.cpp | 10 +- .../dushanbe/GetLastTransactionsScript.cpp | 261 +++++++++--------- android/dushanbe/GetLastTransactionsScript.h | 27 +- 3 files changed, 144 insertions(+), 154 deletions(-) diff --git a/android/dump/TaskDumpRecorder.cpp b/android/dump/TaskDumpRecorder.cpp index 1546a47..248798b 100644 --- a/android/dump/TaskDumpRecorder.cpp +++ b/android/dump/TaskDumpRecorder.cpp @@ -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; } diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index b3a6255..95319f9 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -1,6 +1,7 @@ #include "GetLastTransactionsScript.h" #include +#include #include #include #include @@ -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> targets = m_targets; - std::sort(targets.begin(), targets.end(), + // Single-pass: один проход вниз обслуживает ВСЕ цели сразу. Список Выписки + // идёт сверху-вниз (новые→старые), сортируем цели так же и отдаём в searchAll. + // Найденные он эмитит через transactionParsed и убирает из pending; в остатке — + // ненайденные, по которым собираем агрегированную ошибку. + QList> pending = m_targets; + std::sort(pending.begin(), pending.end(), [](const QPair &a, const QPair &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(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 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(device.screenWidth * it.value()); + const int y = static_cast(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> &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 diff --git a/android/dushanbe/GetLastTransactionsScript.h b/android/dushanbe/GetLastTransactionsScript.h index ab26476..f04cc61 100644 --- a/android/dushanbe/GetLastTransactionsScript.h +++ b/android/dushanbe/GetLastTransactionsScript.h @@ -4,6 +4,7 @@ #include #include #include +#include #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> &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;