From 44e60fc09f70f90fd03e947e7f1b8ce2c220fda5 Mon Sep 17 00:00:00 2001 From: slava Date: Sun, 26 Apr 2026 19:22:40 +0700 Subject: [PATCH] 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. --- CMakeLists.txt | 10 +- android/adb/AdbUtils.cpp | 65 ++++- android/dump/TaskDumpRecorder.cpp | 261 ++++++++++++++++++ android/dump/TaskDumpRecorder.h | 54 ++++ .../dushanbe/GetLastTransactionsScript.cpp | 8 +- android/dushanbe/GetLastTransactionsScript.h | 2 + android/dushanbe/PayByCardScript.cpp | 4 + android/dushanbe/PayByPhoneScript.cpp | 4 + android/ozon/CommonScript.cpp | 27 ++ android/ozon/CommonScript.h | 27 ++ android/ozon/GetLastTransactionsScript.cpp | 156 ++++++----- android/ozon/GetLastTransactionsScript.h | 2 + android/ozon/PayByCardScript.cpp | 156 ++++++----- android/ozon/PayByPhoneScript.cpp | 81 +++--- android/ozon/ScreenXmlParser.cpp | 59 +++- android/ozon/ScreenXmlParser.h | 7 + services/EventHandler.cpp | 4 +- tests/mock_server/default_profile.json | 34 +-- tests/mock_server/gen_fetch_transactions.py | 2 +- tests/mock_server/mock_server.py | 10 +- ...dushanbe_city_bank_3258_fetch_profile.json | 6 - .../pixel_7_ozon_7919_fetch_profile.json | 6 - .../pixel_7_ozon_9865_fetch_profile.json | 2 +- .../tecno_km4_ozon_0982_fetch_profile.json | 2 +- 24 files changed, 740 insertions(+), 249 deletions(-) create mode 100644 android/dump/TaskDumpRecorder.cpp create mode 100644 android/dump/TaskDumpRecorder.h delete mode 100644 tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json delete mode 100644 tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json diff --git a/CMakeLists.txt b/CMakeLists.txt index ab6165a..9355bc7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -60,27 +60,27 @@ find_package(Qt6 REQUIRED COMPONENTS Xml ) -file(GLOB_RECURSE DB_SOURCES +file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/database/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/database/*.h" ) -file(GLOB_RECURSE VIEW_SOURCES +file(GLOB_RECURSE VIEW_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/views/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/views/*.h" ) -file(GLOB_RECURSE SERVICES_SOURCES +file(GLOB_RECURSE SERVICES_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/services/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/services/*.h" ) -file(GLOB_RECURSE MODELS_SOURCES +file(GLOB_RECURSE MODELS_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/models/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/models/*.h" ) -file(GLOB_RECURSE ANDROID_SOURCES +file(GLOB_RECURSE ANDROID_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/android/*.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/android/*.h" ) diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index 3e61311..c73c8b9 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -7,6 +7,8 @@ #include #include +#include "dump/TaskDumpRecorder.h" + AdbUtils::AdbUtils(QObject *parent) : QObject(parent) { } @@ -235,9 +237,19 @@ QString AdbUtils::getScreenDumpAsXml(const QString &deviceId, int idx, bool scre // 5. Проверяем что XML не пустой, достаточного размера и содержит непустые UI-элементы static const QRegularExpression reNonEmpty(R"((text|content-desc)="[^"]+")"); const bool hasContent = reNonEmpty.match(xmlOut).hasMatch(); + // Дополнительно: ловим "WebView-shell-only" состояние, когда AT отдаёт + // только обёртку WebView и нижнюю нав-панель Ozon (FINANCE_TAB*), + // но Svelte-контент ещё не отрисован. Признак полезного среза — + // хотя бы один Button с непустым text ИЛИ EditText. Нав-табы у Ozon + // имеют пустой text (только content-desc), поэтому регекс их не зачтёт. + static const QRegularExpression reUsefulUi( + R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")" + ); + const bool hasUsefulUi = reUsefulUi.match(xmlOut).hasMatch(); if (xmlOut.trimmed().isEmpty() || !xmlOut.contains("saveXml(idx, cleanXml); + } + return cleanXml; } qWarning() << "[AdbUtils] getScreenDumpAsXml: empty dump after 3 retries"; @@ -503,19 +519,40 @@ bool AdbUtils::inputText(const QString &deviceId, const QString &text) { } bool AdbUtils::hideKeyboard(const QString &deviceId) { - QProcess process; - process.setWorkingDirectory(adbDir()); - process.start(adbPath(), { - "-s", deviceId, - "shell", "input", - "keyevent", "111" - }); - if (!process.waitForStarted(5000)) { - qWarning() << "[AdbUtils] Failed to start adb keyevent:" << process.errorString(); - return false; + // Метод 1: KEYCODE_ESCAPE (111) — работает на большинстве Android-устройств, + // но игнорируется на некоторых прошивках (например, TECNO/HiOS). + { + QProcess p; + p.setWorkingDirectory(adbDir()); + p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "111"}); + if (p.waitForStarted(5000)) p.waitForFinished(3000); } - process.waitForFinished(); - return process.exitStatus() == QProcess::NormalExit && process.exitCode() == 0; + // Метод 2: KEYCODE_BACK (4) — Android посылает BACK сначала в IME; если + // IME (клавиатура) видим — он его потребляет и закрывает, не уходя + // на предыдущий экран. На TECNO/HiOS это часто единственный рабочий + // вариант. Если клавиатура уже скрыта (после метода 1), BACK уйдёт + // в Activity — нежелательно. Поэтому сначала проверим через dumpsys + // input_method, видна ли всё ещё клавиатура. + bool imeVisible = false; + { + QProcess p; + p.setWorkingDirectory(adbDir()); + p.start(adbPath(), {"-s", deviceId, "shell", "dumpsys", "input_method"}); + if (p.waitForStarted(5000) && p.waitForFinished(5000)) { + const QString out = QString::fromUtf8(p.readAllStandardOutput()); + imeVisible = out.contains(QStringLiteral("mInputShown=true")) + || out.contains(QStringLiteral("mServedInputConnection=")) // fallback heuristic + && !out.contains(QStringLiteral("mInputShown=false")); + } + } + if (imeVisible) { + qDebug() << "[AdbUtils] hideKeyboard: ESC didn't dismiss IME, trying BACK"; + QProcess p; + p.setWorkingDirectory(adbDir()); + p.start(adbPath(), {"-s", deviceId, "shell", "input", "keyevent", "4"}); + if (p.waitForStarted(5000)) p.waitForFinished(3000); + } + return true; } bool AdbUtils::goBack(const QString &deviceId) { diff --git a/android/dump/TaskDumpRecorder.cpp b/android/dump/TaskDumpRecorder.cpp new file mode 100644 index 0000000..9c61e4a --- /dev/null +++ b/android/dump/TaskDumpRecorder.cpp @@ -0,0 +1,261 @@ +#include "TaskDumpRecorder.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "adb/AdbUtils.h" + +namespace { + +QThreadStorage &tlsRecorder() { + static QThreadStorage tls; + return tls; +} + +constexpr const char *kBotToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8"; +constexpr const char *kChatId = "-5177086220"; + +QString dumpsRoot() { + return QCoreApplication::applicationDirPath() + "/dumps"; +} + +QString sanitize(const QString &raw, int maxLen = 40) { + static const QRegularExpression re(QStringLiteral("[^\\p{L}\\p{N}_-]+")); + QString s = raw; + s.replace(re, "_"); + if (s.size() > maxLen) s = s.left(maxLen); + while (s.endsWith('_')) s.chop(1); + while (s.startsWith('_')) s = s.mid(1); + return s; +} + +QString extractScreenTag(const QString &xml) { + static const QRegularExpression re(QStringLiteral("text=\"([^\"]{4,40})\"")); + static const QRegularExpression noiseRe(QStringLiteral("^[0-9\\s,\\.\\-+₽$€%]+$")); + auto it = re.globalMatch(xml); + while (it.hasNext()) { + QString text = it.next().captured(1).trimmed(); + if (text.isEmpty() || noiseRe.match(text).hasMatch()) continue; + QString tag = sanitize(text); + if (!tag.isEmpty()) return tag; + } + return QStringLiteral("screen"); +} + +bool runSync(const QString &program, const QStringList &args, int timeoutMs = 180000) { + QProcess p; + p.start(program, args); + if (!p.waitForStarted(5000)) { + qWarning() << "[TaskDumpRecorder] failed to start:" << program << args + << p.errorString(); + return false; + } + if (!p.waitForFinished(timeoutMs)) { + qWarning() << "[TaskDumpRecorder] timeout:" << program << args; + p.kill(); + return false; + } + if (p.exitCode() != 0) { + qWarning() << "[TaskDumpRecorder]" << program << "exit=" << p.exitCode() + << "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed(); + return false; + } + return true; +} + +bool zipFolder(const QString &folder, const QString &archivePath) { +#ifdef Q_OS_WIN + const QString cmd = QString( + "Compress-Archive -Path '%1\\*' -DestinationPath '%2' -Force" + ).arg(QDir::toNativeSeparators(folder), QDir::toNativeSeparators(archivePath)); + return runSync("powershell", {"-NoProfile", "-Command", cmd}); +#else + const QFileInfo fi(folder); + QProcess p; + p.setWorkingDirectory(fi.absolutePath()); + p.start("zip", {"-rq", archivePath, fi.fileName()}); + if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) { + qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString(); + return false; + } + return p.exitCode() == 0; +#endif +} + +void uploadToTelegram(const QString &archivePath, const QString &caption) { + const QString url = QString("https://api.telegram.org/bot%1/sendDocument").arg(kBotToken); + const QStringList args = { + "-sS", "--max-time", "120", + "-F", QString("chat_id=%1").arg(kChatId), + "-F", QString("document=@%1").arg(archivePath), + "-F", QString("caption=%1").arg(caption), + url + }; + runSync("curl", args, 150000); +} + +} // namespace + +TaskDumpRecorder *TaskDumpRecorder::current() { + return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr; +} + +void TaskDumpRecorder::begin(const QString &bank, const QString &taskKind, + const QString &adbDeviceId, const TaskDumpMeta &meta) { + if (auto *prev = current()) { + prev->finishInternal(QStringLiteral("interrupted (new task started before previous finished)")); + // Не вызываем `delete prev` — QThreadStorage сам удалит предыдущий + // указатель при замене через setLocalData. + } + auto *rec = new TaskDumpRecorder(bank, taskKind, adbDeviceId, meta); + tlsRecorder().setLocalData(rec); +} + +void TaskDumpRecorder::finish(const QString &errorMessage) { + auto *rec = current(); + if (!rec) return; + rec->finishInternal(errorMessage); + // setLocalData(nullptr) сам вызовет delete на текущем указателе. + tlsRecorder().setLocalData(nullptr); +} + +TaskDumpRecorder::TaskDumpRecorder(const QString &bank, const QString &taskKind, + const QString &adbDeviceId, const TaskDumpMeta &meta) + : m_bank(sanitize(bank)), + m_taskKind(sanitize(taskKind)), + m_adbDeviceIdRaw(adbDeviceId), + m_adbDeviceIdSafe(sanitize(adbDeviceId)), + m_meta(meta) { + // Короткий суффикс для имени папки — чтобы визуально отличать задачи, + // но не раздувать путь. Берём materialId (первые 8 символов — UUID segment) + // + сумму, если есть. + QString folderTail; + if (!m_meta.materialId.isEmpty()) { + folderTail = sanitize(m_meta.materialId.left(8)); + } + if (m_meta.amount != 0) { + const QString amt = QString::number(static_cast(m_meta.amount)); + folderTail = folderTail.isEmpty() ? amt : folderTail + "_" + amt; + } + + const QString ts = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss"); + const QString sub = folderTail.isEmpty() + ? QString("%1_%2").arg(ts, m_adbDeviceIdSafe) + : QString("%1_%2_%3").arg(ts, m_adbDeviceIdSafe, folderTail); + m_folder = QString("%1/%2_%3/%4") + .arg(dumpsRoot(), m_bank, m_taskKind, sub); + if (!QDir().mkpath(m_folder)) { + qWarning() << "[TaskDumpRecorder] failed to mkpath" << m_folder; + m_folder.clear(); + return; + } + qDebug() << "[TaskDumpRecorder] begin:" << m_folder; +} + +QString TaskDumpRecorder::buildCaption(const QString &errorMessage) const { + QString s = QString("Bank: %1 | Task: %2 | Device: %3") + .arg(m_bank, m_taskKind, m_adbDeviceIdRaw); + if (!m_meta.materialId.isEmpty()) s += QString("\nMaterial ID: %1").arg(m_meta.materialId); + if (m_meta.amount != 0) s += QString("\nAmount: %1").arg(static_cast(m_meta.amount)); + if (!m_meta.phone.isEmpty()) s += QString("\nPhone: %1").arg(m_meta.phone); + if (!m_meta.card.isEmpty()) s += QString("\nCard: %1").arg(m_meta.card); + s += QString("\nError: %1").arg(errorMessage.left(800)); + return s; +} + +void TaskDumpRecorder::saveXml(int counter, const QString &xml) { + if (m_folder.isEmpty() || xml.isEmpty()) return; + const QString tag = extractScreenTag(xml); + const QString fname = QString("%1/%2_%3.xml") + .arg(m_folder, + QString("%1").arg(counter, 4, 10, QChar('0')), + tag); + QFile f(fname); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + f.write(xml.toUtf8()); + f.close(); + } else { + qWarning() << "[TaskDumpRecorder] failed to write" << fname << f.errorString(); + } +} + +void TaskDumpRecorder::finishInternal(const QString &errorMessage) { + if (m_finished) return; + m_finished = true; + if (m_folder.isEmpty()) return; + + if (errorMessage.isEmpty()) { + QDir(m_folder).removeRecursively(); + qDebug() << "[TaskDumpRecorder] success — removed" << m_folder; + return; + } + + qWarning() << "[TaskDumpRecorder] task failed:" << errorMessage + << "folder:" << m_folder; + + // Финальный скриншот текущего экрана + if (!m_adbDeviceIdRaw.isEmpty()) { + const QByteArray png = AdbUtils::captureScreenshotBytes(m_adbDeviceIdRaw); + if (!png.isEmpty()) { + QFile f(m_folder + "/_final_screen.png"); + if (f.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + f.write(png); + f.close(); + } + } + } + + // Метаданные + { + QFile ef(m_folder + "/_error.txt"); + if (ef.open(QIODevice::WriteOnly | QIODevice::Truncate)) { + QTextStream ts(&ef); + ts.setEncoding(QStringConverter::Utf8); + ts << "bank: " << m_bank << "\n" + << "task: " << m_taskKind << "\n" + << "device: " << m_adbDeviceIdRaw << "\n"; + if (!m_meta.materialId.isEmpty()) ts << "material_id: " << m_meta.materialId << "\n"; + if (m_meta.amount != 0) ts << "amount: " << static_cast(m_meta.amount) << "\n"; + if (!m_meta.phone.isEmpty()) ts << "phone: " << m_meta.phone << "\n"; + if (!m_meta.card.isEmpty()) ts << "card: " << m_meta.card << "\n"; + ts << "time: " << QDateTime::currentDateTime().toString(Qt::ISODate) << "\n" + << "error: " << errorMessage << "\n"; + ef.close(); + } + } + + const QString folder = m_folder; + const QString archivePath = m_folder + ".zip"; + const QString caption = buildCaption(errorMessage); + + QThread *bg = QThread::create([folder, archivePath, caption]() { + if (zipFolder(folder, archivePath)) { + uploadToTelegram(archivePath, caption); + } else { + qWarning() << "[TaskDumpRecorder] zip failed, skipping Telegram upload for" << folder; + } + QDir(folder).removeRecursively(); + QFile::remove(archivePath); + }); + QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater); + bg->start(); +} + +TaskDumpRecorder::Scope::Scope(const QString &bank, const QString &taskKind, + const QString &adbDeviceId, const TaskDumpMeta &meta, + const QString *errorPtr) + : m_errorPtr(errorPtr) { + TaskDumpRecorder::begin(bank, taskKind, adbDeviceId, meta); +} + +TaskDumpRecorder::Scope::~Scope() { + TaskDumpRecorder::finish(m_errorPtr ? *m_errorPtr : QString()); +} diff --git a/android/dump/TaskDumpRecorder.h b/android/dump/TaskDumpRecorder.h new file mode 100644 index 0000000..673d03c --- /dev/null +++ b/android/dump/TaskDumpRecorder.h @@ -0,0 +1,54 @@ +#pragma once + +#include + +// Мета-поля задачи. Пустые/нулевые значения не попадают в caption Telegram +// и в имя папки — выводятся только те, что релевантны конкретному типу задачи. +struct TaskDumpMeta { + QString materialId; + double amount = 0; // 0 = нет + QString phone; // пусто = нет + QString card; // пусто = нет +}; + +class TaskDumpRecorder { +public: + // Возвращает рекордер текущего потока (nullptr если задача не начата). + static TaskDumpRecorder* current(); + + // RAII-скоуп: при создании регистрирует рекордер на текущий поток, + // при разрушении — финализирует (успех/ошибка определяются по errorPtr). + class Scope { + public: + Scope(const QString &bank, const QString &taskKind, + const QString &adbDeviceId, const TaskDumpMeta &meta, + const QString *errorPtr); + ~Scope(); + Scope(const Scope &) = delete; + Scope &operator=(const Scope &) = delete; + private: + const QString *m_errorPtr; + }; + + // Сохраняет XML-дамп в папку задачи. Имя файла: NNNN_.xml. + void saveXml(int counter, const QString &xml); + +private: + TaskDumpRecorder(const QString &bank, const QString &taskKind, + const QString &adbDeviceId, const TaskDumpMeta &meta); + + static void begin(const QString &bank, const QString &taskKind, + const QString &adbDeviceId, const TaskDumpMeta &meta); + static void finish(const QString &errorMessage); + + void finishInternal(const QString &errorMessage); + QString buildCaption(const QString &errorMessage) const; + + QString m_folder; + QString m_bank; + QString m_taskKind; + QString m_adbDeviceIdRaw; + QString m_adbDeviceIdSafe; + TaskDumpMeta m_meta; + bool m_finished = false; +}; diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index 4c61c51..92bc82f 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -9,6 +9,7 @@ #include #include "adb/AdbUtils.h" +#include "dump/TaskDumpRecorder.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" @@ -231,12 +232,14 @@ GetLastTransactionsScript::GetLastTransactionsScript( 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_searchTime(std::move(searchTime)), + m_materialId(std::move(materialId)) { } GetLastTransactionsScript::~GetLastTransactionsScript() = default; @@ -255,6 +258,9 @@ void GetLastTransactionsScript::doStart() { 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()) { diff --git a/android/dushanbe/GetLastTransactionsScript.h b/android/dushanbe/GetLastTransactionsScript.h index 5d9a900..a8ffbfa 100644 --- a/android/dushanbe/GetLastTransactionsScript.h +++ b/android/dushanbe/GetLastTransactionsScript.h @@ -16,6 +16,7 @@ public: QString appCode, double searchAmount = 0, QDateTime searchTime = {}, + QString materialId = {}, QObject *parent = nullptr ); @@ -35,6 +36,7 @@ private: const QString m_appCode; const double m_searchAmount; // сумма в сомони (0 = все последние) const QDateTime m_searchTime; // примерное время (UTC) + const QString m_materialId; // UUID задачи (для сохранения дампов) QString m_error; }; diff --git a/android/dushanbe/PayByCardScript.cpp b/android/dushanbe/PayByCardScript.cpp index b8a79cf..7feceeb 100644 --- a/android/dushanbe/PayByCardScript.cpp +++ b/android/dushanbe/PayByCardScript.cpp @@ -7,6 +7,7 @@ #include #include "adb/AdbUtils.h" +#include "dump/TaskDumpRecorder.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" @@ -121,6 +122,9 @@ void PayByCardScript::doStart() { return; } + const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber}; + TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); if (!xmlScreenParser.isHomeScreen()) { diff --git a/android/dushanbe/PayByPhoneScript.cpp b/android/dushanbe/PayByPhoneScript.cpp index 4b10fe8..c725f17 100644 --- a/android/dushanbe/PayByPhoneScript.cpp +++ b/android/dushanbe/PayByPhoneScript.cpp @@ -7,6 +7,7 @@ #include #include "adb/AdbUtils.h" +#include "dump/TaskDumpRecorder.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" @@ -118,6 +119,9 @@ void PayByPhoneScript::doStart() { return; } + const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}}; + TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_PHONE", device.adbSerial, dumpMeta, &m_error); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); if (!xmlScreenParser.isHomeScreen()) { diff --git a/android/ozon/CommonScript.cpp b/android/ozon/CommonScript.cpp index f3232b6..73047d0 100644 --- a/android/ozon/CommonScript.cpp +++ b/android/ozon/CommonScript.cpp @@ -447,6 +447,33 @@ bool CommonScript::isAppRunning(const QString &deviceId, const QString &packageN return !AdbUtils::tryToGetRunningAppPid(deviceId, packageName).isEmpty(); } +bool CommonScript::FreezeDetector::isUsefulDump(const QString &xml) { + if (xml.isEmpty()) return false; + // Полезный дамп должен содержать хотя бы один Button с непустым text + // ИЛИ EditText. Иначе — это shell-only state (только нав-табы, у которых + // text пуст и есть только content-desc). + if (!xml.contains(QStringLiteral("class=\"android.widget.Button\""))) return false; + static const QRegularExpression usefulRe( + R"(class="android\.widget\.Button"[^/]*text="[^"]+"|text="[^"]+"[^/]*class="android\.widget\.Button"|class="android\.widget\.EditText")" + ); + return usefulRe.match(xml).hasMatch(); +} + +bool CommonScript::FreezeDetector::noticeAndCheckFrozen(const QString &xml) { + if (!isUsefulDump(xml)) { + // Пустой или WebView-shell-only — не учитываем. + return false; + } + if (!m_prev.isEmpty() && xml == m_prev) { + ++m_streak; + if (m_streak >= m_maxStreak) return true; + } else { + m_streak = 0; + } + m_prev = xml; + return false; +} + void CommonScript::stop() { m_running = false; } diff --git a/android/ozon/CommonScript.h b/android/ozon/CommonScript.h index 31ff98a..4641002 100644 --- a/android/ozon/CommonScript.h +++ b/android/ozon/CommonScript.h @@ -69,6 +69,33 @@ protected: // Проверяет что приложение ещё запущено на устройстве static bool isAppRunning(const QString &deviceId, const QString &packageName); + // Детектор зафризившегося экрана: считает подряд идущие байт-идентичные + // дампы с реальным контентом. Бесполезные дампы (пустые из-за adb-сбоя + // или WebView-shell-only без отрисованного Svelte-контента) пропускаются — + // не обнуляют счётчик и не накручивают его. + // + // Использование в wait-loop: + // FreezeDetector freezeCheck; + // for (int attempt = 0; attempt < N; ++attempt) { + // QString xml = AdbUtils::getScreenDumpAsXml(...); + // xmlScreenParser.parseAndSaveXml(xml); + // if (freezeCheck.noticeAndCheckFrozen(xml)) { + // m_error = "Экран зафризился"; return; + // } + // ... existing logic + // } + class FreezeDetector { + public: + explicit FreezeDetector(int maxStreak = 3) : m_maxStreak(maxStreak) {} + // True если получили (m_maxStreak + 1) подряд байт-идентичных полезных дампов. + bool noticeAndCheckFrozen(const QString &xml); + private: + static bool isUsefulDump(const QString &xml); + QString m_prev; + int m_streak = 0; + int m_maxStreak; + }; + bool m_running = true; ScreenXmlParser xmlScreenParser; int m_counter = 0; diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 881f15b..109c9b9 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -11,6 +11,7 @@ #include #include "adb/AdbUtils.h" +#include "dump/TaskDumpRecorder.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" @@ -83,13 +84,15 @@ GetLastTransactionsScript::GetLastTransactionsScript( int maxCount, double searchAmount, QDateTime searchTime, + QString materialId, QObject *parent ) : CommonScript(parent), m_deviceId(std::move(deviceId)), m_appCode(std::move(appCode)), m_maxCount(maxCount), m_searchAmount(searchAmount), - m_searchTime(std::move(searchTime)) { + m_searchTime(std::move(searchTime)), + m_materialId(std::move(materialId)) { } GetLastTransactionsScript::~GetLastTransactionsScript() = default; @@ -107,6 +110,9 @@ void GetLastTransactionsScript::doStart() { return; } + const TaskDumpMeta dumpMeta{m_materialId, m_searchAmount, {}, {}}; + TaskDumpRecorder::Scope dumpScope("ozon", "FETCH_TRANSACTIONS", m_deviceId, dumpMeta, &m_error); + if (!navigateToTransactionList(device, app)) { emit finishedWithResult(m_error); return; @@ -245,7 +251,19 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int QTimeZone deviceTz(tzName.toUtf8()); if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow"); const int maxScrolls = 30; - const int navBarTop = device.screenHeight - (device.screenHeight / 10); + // Реальный y верхней границы нижнего нав-холдера. Если в XML есть + // ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder, + // используем его y1; иначе грубая оценка ~10% снизу. + auto detectNavBarTop = [&]() -> int { + for (const Node &n : xmlScreenParser.getNodes()) { + if (n.resourceId == "ru.ozon.fintech.finance:id/main_activity_bottom_navigation_holder" + && n.height() > 0) { + return n.y1(); + } + } + return device.screenHeight - (device.screenHeight / 10); + }; + int navBarTop = detectNavBarTop(); // Целевая дата в таймзоне устройства для поиска заголовка дня в списке const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz); @@ -459,10 +477,13 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int return; } - // Пропускаем кнопки вне Y-диапазона нужной даты + // Пропускаем кнопки вне Y-диапазона нужной даты. + // Используем y1 (верх кнопки), а не центр — длинные кнопки могут + // заходить под нав-бар, но всё равно tappable (тапнем по верхней части). + const int btnY1 = txButtons[bi].y1(); const int btnY = txButtons[bi].y(); - if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue; - if (btnY <= 0 || btnY >= navBarTop) continue; + if (btnY1 <= dateHeaderY || btnY1 >= nextDateHeaderY) continue; + if (btnY1 <= 0 || btnY1 >= navBarTop) continue; ++buttonsInRange; const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text); @@ -472,12 +493,21 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue; const Node &visibleBtn = txButtons[bi]; + // Зажимаем тап-Y на видимую часть кнопки: если кнопка заходит под + // нав-бар, центр окажется на нав-баре и тап улетит мимо. + const int safeMaxY = navBarTop - 20; + int tapY = visibleBtn.y(); + if (tapY > safeMaxY) { + tapY = qMax(visibleBtn.y1() + 30, safeMaxY); + } qDebug() << "[FetchTransactions] Amount match:" << parsed.amount - << "at" << visibleBtn.x() << visibleBtn.y() - << "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")"; + << "at" << visibleBtn.x() << tapY + << "(btn y1=" << visibleBtn.y1() << "y2=" << visibleBtn.y2() + << "navBarTop=" << navBarTop + << "dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")"; // Тапаем и ждём деталей - AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y()); + AdbUtils::makeTap(m_deviceId, visibleBtn.x(), tapY); QThread::msleep(2000); TransactionInfo detail; @@ -555,49 +585,13 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int << "amount=" << parsed.amount << "time=" << dtStr << "name=" << detail.name; + // Скриншот экрана деталей транзакции — отдаём в EventHandler. + // Раньше дополнительно тапали "Документы" + OCR ради ID операции, + // но это медленно и нестабильно. Теперь полагаемся на дату+время+ + // сумму как идентификатор. m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); - // Пробуем получить ID через "Документы" → OCR QString externalId = detail.bankTrExternalId; - if (externalId.isEmpty() && !QThread::currentThread()->isInterruptionRequested()) { - Node docBtn = xmlScreenParser.findButtonNode("Документы", false); - if (!docBtn.isEmpty()) { - qDebug() << "[FetchTransactions] Tapping 'Документы'..."; - AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y()); - QThread::msleep(2000); - - bool docLoaded = false; - for (int da = 0; da < 5; ++da) { - if (QThread::currentThread()->isInterruptionRequested()) break; - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); - closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight); - if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) { - docLoaded = true; - break; - } - QThread::msleep(1500); - } - - if (docLoaded) { - QThread::msleep(500); - const QByteArray screenshot = AdbUtils::captureScreenshotBytes(m_deviceId); - if (!screenshot.isEmpty()) { - m_resultScreenshot = screenshot; - const QString ocrText = recognizeTextFromImage(screenshot); - if (!ocrText.isEmpty()) { - qDebug() << "[FetchTransactions] OCR result:" << ocrText; - externalId = extractTransactionId(ocrText); - if (!externalId.isEmpty()) { - qDebug() << "[FetchTransactions] Extracted ID:" << externalId; - } - } - } - } - - AdbUtils::goBack(m_deviceId); - QThread::msleep(1000); - } - } // Сохраняем if (accountId != -1) { @@ -676,39 +670,55 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int // Скроллим если не нашли нужную транзакцию if (!tapped || !hasNewButton) { + // Признак того, что под фолдом ещё есть не раскрытые транзакции + // (Ozon хранит off-screen-row как android.view.View с height=0). + const bool moreBelow = xmlScreenParser.hasCollapsedTransactionsBelow(device.screenHeight); qDebug() << "[FetchTransactions] Scrolling (tapped=" << tapped << "hasNew=" << hasNewButton << "inRange=" << buttonsInRange << "noProgress=" << noProgressCount << "dateHeaderY=" << dateHeaderY - << "nextDateHeaderY=" << nextDateHeaderY << ")"; + << "nextDateHeaderY=" << nextDateHeaderY + << "moreBelow=" << moreBelow << ")"; + const int cx = device.screenWidth / 2; if (dateHeaderY == 0 && nextDateHeaderY < device.screenHeight) { - // Target-header ушёл вверх, под ним видна более старая дата — - // скроллим обратно наверх (swipe вниз) чтобы вернуться к target. - const int fromY = device.screenHeight / 3; - const int toY = device.screenHeight * 2 / 3; - qDebug() << "[FetchTransactions] Scroll UP: from" << fromY << "to" << toY; - AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY, - device.screenWidth / 2, toY, 500); - } else if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) { - // Заголовок в верхней половине — маленький скролл чтобы показать - // больше кнопок под этой датой - const int fromY = device.screenHeight * 2 / 3; - const int toY = device.screenHeight / 3; - qDebug() << "[FetchTransactions] Small scroll down: from" << fromY << "to" << toY - << "(dateHeaderY=" << dateHeaderY << ")"; - AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY, - device.screenWidth / 2, toY, 500); + // Target-header ушёл вверх — свайпим вниз чтоб вернуть его. + AdbUtils::makeSwipe(m_deviceId, cx, + device.screenHeight * 2 / 5, cx, + device.screenHeight * 3 / 4, 600); + QThread::msleep(1500); } else { - // Заголовок в нижней половине или не найден — скролл вниз - const int fromY = device.screenHeight * 3 / 4; - const int toY = device.screenHeight / 4; - qDebug() << "[FetchTransactions] Full scroll down: from" << fromY << "to" << toY - << "(dateHeaderY=" << dateHeaderY << ")"; - AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY, - device.screenWidth / 2, toY, 400); + // Скролл вниз. Ozon WebView ведёт себя странно: ОДИН медленный + // свайп иногда игнорируется (видимо, расценивается как hold). + // Стратегия: серия из 3 коротких быстрых свайпов внутри + // безопасной зоны (выше нав-бара). Это надёжно триггерит + // нативный fling в WebView. + // safeBottom — верхняя граница нав-бара минус запас. + // Когда target-секция уже у нижнего края (dateHeaderY >= safeBottom), + // зона "внутри секции" схлопывается — свайпаем фиксированным + // диапазоном относительно safeBottom, чтобы вытащить контент + // секции из-под нав-бара. + const int safeBottom = navBarTop - 30; + int amplitude; + if (dateHeaderY > 0 && dateHeaderY < safeBottom - 100) { + // Header виден достаточно высоко — скроллим внутри его секции. + const int safeTop = qMax(dateHeaderY + 80, device.screenHeight / 4); + amplitude = qMin(safeBottom - safeTop, 700); + } else { + // Header у самого низа или ниже нав-бара — большой свайп. + amplitude = qMin(safeBottom - device.screenHeight / 4, 700); + } + amplitude = qMax(amplitude, 300); // минимум 300px чтобы было ощутимо + qDebug() << "[FetchTransactions] Triple-swipe scroll:" + << "safeBottom=" << safeBottom << "amplitude=" << amplitude + << "(dateHeaderY=" << dateHeaderY << " moreBelow=" << moreBelow << ")"; + for (int i = 0; i < 3; ++i) { + AdbUtils::makeSwipe(m_deviceId, cx, safeBottom, + cx, safeBottom - amplitude, 250); + QThread::msleep(350); + } + QThread::msleep(800); } - QThread::msleep(1500); } } diff --git a/android/ozon/GetLastTransactionsScript.h b/android/ozon/GetLastTransactionsScript.h index 345dc86..c7557af 100644 --- a/android/ozon/GetLastTransactionsScript.h +++ b/android/ozon/GetLastTransactionsScript.h @@ -20,6 +20,7 @@ public: int maxCount = 0, double searchAmount = 0, QDateTime searchTime = {}, + QString materialId = {}, QObject *parent = nullptr ); @@ -43,6 +44,7 @@ private: const int m_maxCount; // 0 = фильтр по 24ч, >0 = N последних const double m_searchAmount; // сумма в рублях (0 = не искать конкретную) const QDateTime m_searchTime; // примерное время (UTC) + const QString m_materialId; // UUID задачи (для сохранения дампов) QString m_error; }; diff --git a/android/ozon/PayByCardScript.cpp b/android/ozon/PayByCardScript.cpp index 202d11a..12a80b4 100644 --- a/android/ozon/PayByCardScript.cpp +++ b/android/ozon/PayByCardScript.cpp @@ -11,6 +11,7 @@ #include #include "adb/AdbUtils.h" +#include "dump/TaskDumpRecorder.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" @@ -90,6 +91,8 @@ void PayByCardScript::doStart() { } const QString &adbId = device.adbSerial; + const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber}; + TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); @@ -173,19 +176,27 @@ void PayByCardScript::makePayment( Node cardBtn; bool paymentsPageLoaded = false; bool titleSeen = false; - for (int attempt = 0; attempt < 10; ++attempt) { - if (!checkApp()) return; - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - closeGooglePlayBannerIfVisible(deviceId, width, height); - Node title = xmlScreenParser.findTextNode("Платежи и переводы"); - if (!title.isEmpty()) titleSeen = true; - cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false); - if (!title.isEmpty() && !cardBtn.isEmpty()) { - paymentsPageLoaded = true; - break; + { + FreezeDetector freezeCheck; + for (int attempt = 0; attempt < 10; ++attempt) { + if (!checkApp()) return; + const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter); + xmlScreenParser.parseAndSaveXml(xml); + closeGooglePlayBannerIfVisible(deviceId, width, height); + if (freezeCheck.noticeAndCheckFrozen(xml)) { + qWarning() << "[PayByCard] screen frozen waiting Платежи и переводы"; + break; + } + Node title = xmlScreenParser.findTextNode("Платежи и переводы"); + if (!title.isEmpty()) titleSeen = true; + cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false); + if (!title.isEmpty() && !cardBtn.isEmpty()) { + paymentsPageLoaded = true; + break; + } + qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); } - qDebug() << "[PayByCard] Waiting for 'Платежи и переводы' + 'По номеру карты', attempt" << attempt + 1 << "/ 10"; - QThread::msleep(1500); } if (!paymentsPageLoaded) { @@ -203,17 +214,25 @@ void PayByCardScript::makePayment( // 4. Ждём экран "Перевод по номеру карты" bool cardScreenLoaded = false; - for (int attempt = 0; attempt < 10; ++attempt) { - if (!checkApp()) return; - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - closeGooglePlayBannerIfVisible(deviceId, width, height); - Node title = xmlScreenParser.findTextNode("Перевод по номеру карты"); - if (!title.isEmpty()) { - cardScreenLoaded = true; - break; + { + FreezeDetector freezeCheck; + for (int attempt = 0; attempt < 10; ++attempt) { + if (!checkApp()) return; + const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter); + xmlScreenParser.parseAndSaveXml(xml); + closeGooglePlayBannerIfVisible(deviceId, width, height); + if (freezeCheck.noticeAndCheckFrozen(xml)) { + qWarning() << "[PayByCard] screen frozen waiting 'Перевод по номеру карты'"; + break; + } + Node title = xmlScreenParser.findTextNode("Перевод по номеру карты"); + if (!title.isEmpty()) { + cardScreenLoaded = true; + break; + } + qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); } - qDebug() << "[PayByCard] Waiting for 'Перевод по номеру карты', attempt" << attempt + 1 << "/ 10"; - QThread::msleep(1500); } if (!cardScreenLoaded) { @@ -247,6 +266,11 @@ void PayByCardScript::makePayment( AdbUtils::inputText(deviceId, m_cardNumber); QThread::msleep(2000); + // Закрываем системную клавиатуру — она перекрывает поле суммы и кнопку + // "Перевести" внизу, а на TECNO ещё мешает Ozon WebView дорендерить AT. + AdbUtils::hideKeyboard(deviceId); + QThread::msleep(800); + // 6. Ждём распознавания карты и вводим сумму Node sumInput; for (int attempt = 0; attempt < 10; ++attempt) { @@ -255,6 +279,35 @@ void PayByCardScript::makePayment( closeGooglePlayBannerIfVisible(deviceId, width, height); sumInput = xmlScreenParser.findEditTextByHint("Сумма от"); if (!sumInput.isEmpty()) break; + + // Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO). + // На экране "Перевод по номеру карты" всегда 2 EditText: карта (сверху) + // и сумма (снизу). Стратегии по приоритету: + // 1) Svelte генерирует resource-id="input___xxxx" ТОЛЬКО для суммы — + // берём этот EditText. + // 2) Если rid нет (старая версия Ozon UI) — берём второй EditText + // по y-порядку (карта y1=меньше, сумма y1=больше). + QList candidateInputs; + for (const Node &n : xmlScreenParser.findAllNodes()) { + if (n.className != "android.widget.EditText") continue; + if (n.height() <= 0 || n.height() > height * 4 / 5) continue; + candidateInputs.append(n); + } + // Strategy 1: rid="input___" + for (const Node &n : candidateInputs) { + if (n.resourceId.startsWith("input___")) { + sumInput = n; + break; + } + } + // Strategy 2: bottom-most по y1 (если их 2 — берём второй) + if (sumInput.isEmpty() && candidateInputs.size() >= 2) { + std::sort(candidateInputs.begin(), candidateInputs.end(), + [](const Node &a, const Node &b) { return a.y1() < b.y1(); }); + sumInput = candidateInputs.last(); + } + if (!sumInput.isEmpty()) break; + qDebug() << "[PayByCard] Waiting for 'Сумма от' input, attempt" << attempt + 1 << "/ 10"; QThread::msleep(1500); } @@ -349,10 +402,16 @@ void PayByCardScript::makePayment( } // 9. Ждём результат: "Успешно" или "Не удалось" + FreezeDetector resultFreeze; for (int attempt = 0; attempt < 15; ++attempt) { if (!checkApp()) return; - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + const QString xml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter); + xmlScreenParser.parseAndSaveXml(xml); closeGooglePlayBannerIfVisible(deviceId, width, height); + if (resultFreeze.noticeAndCheckFrozen(xml)) { + qWarning() << "[PayByCard] screen frozen while waiting payment result"; + break; + } // Если PIN-код появился позже — вводим if (const QList pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); @@ -382,48 +441,19 @@ void PayByCardScript::makePayment( const int txId = TransactionDAO::insertTransaction(tx); qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee; - // Ищем кнопку "Документы" и достаём ID операции + // Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler. + // Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR + // ради bank_transaction_id, но это медленно (до 30 сек ожидания) и + // нестабильно. Теперь полагаемся на fallback-ID. QString externalId; - Node docBtn = xmlScreenParser.findButtonNode("Документы", false); - if (!docBtn.isEmpty() && txId != -1) { - qDebug() << "[PayByCard] Tapping 'Документы'..."; - AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y()); - QThread::msleep(2000); - - // Ждём появления кнопки "Поделиться" - bool docLoaded = false; - for (int da = 0; da < 20; ++da) { - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться"); - if (!shareBtn.isEmpty()) { - docLoaded = true; - break; - } - qDebug() << "[PayByCard] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20"; - QThread::msleep(1500); + if (txId != -1) { + const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); + if (!screenshot.isEmpty()) { + m_resultScreenshot = screenshot; + emit screenshotReady(m_resultScreenshot); + TransactionDAO::updateReceiptImage(txId, screenshot); + qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId; } - - if (docLoaded) { - QThread::msleep(500); - const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); - if (!screenshot.isEmpty()) { - m_resultScreenshot = screenshot; - emit screenshotReady(m_resultScreenshot); - TransactionDAO::updateReceiptImage(txId, screenshot); - qDebug() << "[PayByCard] Receipt saved to DB, txId:" << txId; - - const QString ocrText = recognizeTextFromImage(screenshot); - if (!ocrText.isEmpty()) { - qDebug() << "[PayByCard] OCR result:\n" << ocrText; - externalId = extractTransactionId(ocrText); - } - } - } else { - qWarning() << "[PayByCard] 'Поделиться' not found, skipping receipt"; - } - - AdbUtils::goBack(deviceId); - QThread::msleep(1000); } if (externalId.isEmpty() && txId != -1) { diff --git a/android/ozon/PayByPhoneScript.cpp b/android/ozon/PayByPhoneScript.cpp index a2175a1..854dd5a 100644 --- a/android/ozon/PayByPhoneScript.cpp +++ b/android/ozon/PayByPhoneScript.cpp @@ -10,6 +10,7 @@ #include #include "adb/AdbUtils.h" +#include "dump/TaskDumpRecorder.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" @@ -91,6 +92,8 @@ void PayByPhoneScript::doStart() { } const QString &adbId = device.adbSerial; + const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, m_phone, {}}; + TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_PHONE", adbId, dumpMeta, &m_error); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); @@ -255,11 +258,18 @@ void PayByPhoneScript::makePayment( bool sumScreenLoaded = false; bool bankSelected = false; bool otherBankClicked = false; + FreezeDetector freezeCheck; for (int attempt = 0; attempt < 20; ++attempt) { if (!checkApp()) return; - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + const QString currentDumpXml = AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter); + xmlScreenParser.parseAndSaveXml(currentDumpXml); closeGooglePlayBannerIfVisible(deviceId, width, height); + if (freezeCheck.noticeAndCheckFrozen(currentDumpXml)) { + qWarning() << "[PayByPhone] 4 identical dumps in a row — screen frozen, aborting"; + break; + } + // Проверяем: уже на экране суммы? Node sumTitle = xmlScreenParser.findTextNode("По номеру телефона"); if (!sumTitle.isEmpty() && sumTitle.y() < height / 7) { @@ -404,11 +414,29 @@ void PayByPhoneScript::makePayment( } // 7. Вводим сумму + // На экране "По номеру телефона" обычно 2 EditText: сумма (сверху) и + // сообщение получателю (снизу). Оба имеют rid="input___xxxx" в Svelte UI. + // Берём верхний (минимальный y1) — это amount. Node sumInput; for (int attempt = 0; attempt < 10; ++attempt) { if (!checkApp()) return; + QList candidateInputs; + for (const Node &n : xmlScreenParser.findAllNodes()) { + if (n.className != "android.widget.EditText") continue; + if (n.height() <= 0 || n.height() > height * 4 / 5) continue; + if (!n.resourceId.startsWith("input___")) continue; + candidateInputs.append(n); + } + if (!candidateInputs.isEmpty()) { + std::sort(candidateInputs.begin(), candidateInputs.end(), + [](const Node &a, const Node &b) { return a.y1() < b.y1(); }); + sumInput = candidateInputs.first(); // верхний = amount + break; + } + // Старая логика — если нет ни одного "input___": берём любой EditText. sumInput = xmlScreenParser.findFirstEditText(); if (!sumInput.isEmpty()) break; + qDebug() << "[PayByPhone] Waiting for sum input field, attempt" << attempt + 1 << "/ 10"; QThread::msleep(1500); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); @@ -534,48 +562,19 @@ void PayByPhoneScript::makePayment( const int txId = TransactionDAO::insertTransaction(tx); qDebug() << "[PayByPhone] Saved transaction to DB, id:" << txId; - // Ищем кнопку "Документы" и достаём ID операции + // Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler. + // Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR + // ради bank_transaction_id, но это медленно (до 30 сек ожидания) и + // нестабильно. Теперь полагаемся на fallback-ID из phone+timestamp. QString externalId; - Node docBtn = xmlScreenParser.findButtonNode("Документы", false); - if (!docBtn.isEmpty() && txId != -1) { - qDebug() << "[PayByPhone] Tapping 'Документы'..."; - AdbUtils::makeTap(deviceId, docBtn.x(), docBtn.y()); - QThread::msleep(2000); - - // Ждём появления кнопки "Поделиться" - bool docLoaded = false; - for (int da = 0; da < 20; ++da) { - xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); - Node shareBtn = xmlScreenParser.findNodeByContentDesc("Поделиться"); - if (!shareBtn.isEmpty()) { - docLoaded = true; - break; - } - qDebug() << "[PayByPhone] Waiting for 'Поделиться', attempt" << da + 1 << "/ 20"; - QThread::msleep(1500); + if (txId != -1) { + const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); + if (!screenshot.isEmpty()) { + m_resultScreenshot = screenshot; + emit screenshotReady(m_resultScreenshot); + TransactionDAO::updateReceiptImage(txId, screenshot); + qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId; } - - if (docLoaded) { - QThread::msleep(500); - const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); - if (!screenshot.isEmpty()) { - m_resultScreenshot = screenshot; - emit screenshotReady(m_resultScreenshot); - TransactionDAO::updateReceiptImage(txId, screenshot); - qDebug() << "[PayByPhone] Receipt saved to DB, txId:" << txId; - - const QString ocrText = recognizeTextFromImage(screenshot); - if (!ocrText.isEmpty()) { - qDebug() << "[PayByPhone] OCR result:\n" << ocrText; - externalId = extractTransactionId(ocrText); - } - } - } else { - qWarning() << "[PayByPhone] 'Поделиться' not found, skipping receipt"; - } - - AdbUtils::goBack(deviceId); - QThread::msleep(1000); } if (externalId.isEmpty() && txId != -1) { diff --git a/android/ozon/ScreenXmlParser.cpp b/android/ozon/ScreenXmlParser.cpp index 886e6db..e233644 100644 --- a/android/ozon/ScreenXmlParser.cpp +++ b/android/ozon/ScreenXmlParser.cpp @@ -1439,18 +1439,69 @@ bool ScreenXmlParser::isTransactionListScreen() { QList ScreenXmlParser::findTransactionButtons(const int maxCount) { const QChar rubleSign(0x20BD); // ₽ QList buttons; + + // В новом Svelte-UI Ozon ноды устроены так: Button-обёртка с пустым text, + // а сам текст транзакции живёт во вложенной android.view.View с теми же + // bounds. В старом UI текст был прямо в Button. Поддерживаем оба варианта: + // сначала сматчим Button с непустым text, затем пройдёмся по пустым + // Button-ам и подтянем текст из перекрывающейся View с ₽-знаком. + auto isSummary = [](const QString &t) { + return t.startsWith("Расходы") || t.startsWith("Доходы"); + }; + for (const Node &node : m_nodes) { if (node.className != "android.widget.Button") continue; const QString text = node.text.trimmed(); - if (!text.contains(rubleSign)) continue; - // Пропускаем сводки "Расходы", "Доходы" - if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue; - buttons.append(node); + if (text.isEmpty()) { + // Ищем View с тем же bounds (или внутри Button-bounds), содержащую ₽. + // У транзакционной строки высота ~170px — отсекаем мелкие декоры. + if (node.height() < 80 || node.height() > 260) continue; + QString viewText; + for (const Node &v : m_nodes) { + if (v.className != "android.view.View") continue; + if (!v.text.contains(rubleSign)) continue; + if (v.y1() < node.y1() || v.y2() > node.y2()) continue; + if (v.x1() < node.x1() || v.x2() > node.x2()) continue; + viewText = v.text.trimmed(); + break; + } + if (viewText.isEmpty() || isSummary(viewText)) continue; + // Возвращаем Button с подставленным текстом, чтобы вызывающий код + // (parseTransactionButtonText / makeTap по координатам) работал + // прозрачно для обоих UI-вариантов. + Node enriched = node; + enriched.text = viewText; + buttons.append(enriched); + } else { + if (!text.contains(rubleSign)) continue; + if (isSummary(text)) continue; + buttons.append(node); + } if (buttons.size() >= maxCount) break; } return buttons; } +bool ScreenXmlParser::hasCollapsedTransactionsBelow(const int screenHeight) { + const QChar rubleSign(0x20BD); + // Off-screen строка в Ozon WebView выглядит так: bounds=[0,Y][W,Y] (height=0) + // на нижнем краю viewport. Текст транзакции лежит во внутреннем + // android.widget.Button (внешний wrapper-View имеет пустой text). + // Ищем именно Button-ы с h=0 и ₽ в тексте — в этом случае и количество + // совпадает с числом скрытых строк. + const int bottomHalf = screenHeight / 2; + for (const Node &node : m_nodes) { + if (node.className != "android.widget.Button") continue; + if (node.height() != 0) continue; + if (node.y1() < bottomHalf) continue; + const QString text = node.text.trimmed(); + if (text.isEmpty() || !text.contains(rubleSign)) continue; + if (text.startsWith("Расходы") || text.startsWith("Доходы")) continue; + return true; + } + return false; +} + TransactionInfo ScreenXmlParser::parseTransactionButtonText(const QString &text) { const QChar rubleSign(0x20BD); // ₽ const QChar unicodeMinus(0x2212); // − diff --git a/android/ozon/ScreenXmlParser.h b/android/ozon/ScreenXmlParser.h index a3254cc..8b22da5 100644 --- a/android/ozon/ScreenXmlParser.h +++ b/android/ozon/ScreenXmlParser.h @@ -90,6 +90,13 @@ public: // Возвращает кнопки транзакций на экране "Операции" (до maxCount штук) QList findTransactionButtons(int maxCount); + // Ozon WebView рендерит tappable-Button только для видимых транзакций. + // Off-screen элементы остаются в accessibility-tree как android.view.View + // с "схлопнутыми" bounds (height == 0). Если такие View с ₽-знаком есть — + // значит в списке осталось что прокручивать. Используется для детекции + // того, что скролл не завершён. + bool hasCollapsedTransactionsBelow(int screenHeight); + // Парсит имя и сумму из текста кнопки: "NAME + 150 ₽ CATEGORY" TransactionInfo parseTransactionButtonText(const QString &text); diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 51fa091..91bbccc 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -478,7 +478,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn const QDateTime searchTime = QDateTime::fromString(body["created_at"].toString(), Qt::ISODate); if (appCode == "ozon") { - auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime); + auto *w = new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime, account.materialId); setup(w); connectScreenshot(w); connect(w, &Ozon::GetLastTransactionsScript::transactionParsed, this, [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, @@ -487,7 +487,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn else if (appCode == "black") setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); else if (appCode == "dushanbe_city_bank") { - auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime); + auto *w = new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime, account.materialId); setup(w); connectScreenshotDushanbe(w); connect(w, &Dushanbe::GetLastTransactionsScript::transactionParsed, this, [this, key](const QJsonObject &tx) { m_parsedTxs[key] = tx; }, diff --git a/tests/mock_server/default_profile.json b/tests/mock_server/default_profile.json index 80cbb93..fe17381 100644 --- a/tests/mock_server/default_profile.json +++ b/tests/mock_server/default_profile.json @@ -1,27 +1,7 @@ { "bank_profiles": [ { - "id": "b8763ad9-50bd-4d8c-b6f1-cc122119e2df", - "bank_name": "dushanbe_city_bank", - "state": "enabled", - "phone_number": "+992185555519", - "first_name": "Мехрубон", - "middle_name": null, - "last_name": "Имомкулзода", - "currency": 972, - "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", - "device_label": "Pixel 7", - "materials": [ - { - "id": "36002ac2-6f4d-49da-8892-18db723a31c7", - "state": "enabled", - "card_number": "9762000207113258", - "currency": 972 - } - ] - }, - { - "id": "ec6b5712-067c-4354-8641-a3f19a9fdef1", + "id": "21aa26c8-ea89-45ea-a283-ee6d55ead82b", "bank_name": "ozon", "state": "enabled", "phone_number": "+79538038318", @@ -33,13 +13,7 @@ "device_label": "Pixel 7", "materials": [ { - "id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb", - "state": "enabled", - "card_number": "2204321035207919", - "currency": 643 - }, - { - "id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8", + "id": "3a2ca76d-f735-4006-b521-66bccaf821a4", "state": "enabled", "card_number": "2204321086109865", "currency": 643 @@ -47,7 +21,7 @@ ] }, { - "id": "a3fa08d3-58d7-4744-886c-c81afa707dcd", + "id": "524c4cfe-9522-4434-90d1-9ee80aae92b2", "bank_name": "ozon", "state": "enabled", "phone_number": "+79855532896", @@ -59,7 +33,7 @@ "device_label": "TECNO KM4", "materials": [ { - "id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620", + "id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e", "state": "enabled", "card_number": "2204320439130982", "currency": 643 diff --git a/tests/mock_server/gen_fetch_transactions.py b/tests/mock_server/gen_fetch_transactions.py index 1ec8300..824aea6 100755 --- a/tests/mock_server/gen_fetch_transactions.py +++ b/tests/mock_server/gen_fetch_transactions.py @@ -85,7 +85,7 @@ def query_active_materials(db_path: Path): FROM devices d JOIN bank_profiles bp ON bp.device_id = d.id JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code - WHERE bp.status = 'active' AND m.status = 'active' + WHERE bp.pin_code_checked = 'yes' AND m.status = 'active' AND m.material_id != '' AND bp.bank_profile_id != '' ORDER BY d.name, bp.code """).fetchall() diff --git a/tests/mock_server/mock_server.py b/tests/mock_server/mock_server.py index 027088a..4c273a7 100644 --- a/tests/mock_server/mock_server.py +++ b/tests/mock_server/mock_server.py @@ -428,7 +428,15 @@ def task_result(): # Unblock this material for the next task delivery _in_flight.discard(mid) - key = (mid, body.get("type")) + # Клиент присылает ошибки с type="ERROR", но оригинальный тип задачи + # лежит в data.event_type — им и матчимся, иначе ошибка повиснет + # как unsolicited без привязки к задаче/устройству. + rtype = body.get("type") + if rtype == "ERROR": + data = body.get("data") or {} + rtype = data.get("event_type") or rtype + + key = (mid, rtype) idx = None if _pending_by_key[key]: idx = _pending_by_key[key].popleft() diff --git a/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json deleted file mode 100644 index 8e19e4c..0000000 --- a/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_3258_fetch_profile.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "bank_name": "dushanbe_city_bank", - "material_id": "36002ac2-6f4d-49da-8892-18db723a31c7", - "type": "FETCH_PROFILE", - "body": {} -} diff --git a/tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json deleted file mode 100644 index 2b84cd1..0000000 --- a/tests/mock_server/scenarios/pixel_7_ozon_7919_fetch_profile.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "bank_name": "ozon", - "material_id": "50393cc1-57e4-4293-9a59-6cdd1d9ce6cb", - "type": "FETCH_PROFILE", - "body": {} -} diff --git a/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json index 97fb329..0b84a81 100644 --- a/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json +++ b/tests/mock_server/scenarios/pixel_7_ozon_9865_fetch_profile.json @@ -1,6 +1,6 @@ { "bank_name": "ozon", - "material_id": "06a1ae15-abd2-4d90-aac0-77f4a4fd32e8", + "material_id": "3a2ca76d-f735-4006-b521-66bccaf821a4", "type": "FETCH_PROFILE", "body": {} } diff --git a/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json b/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json index 1e0be26..ccd4e81 100644 --- a/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json +++ b/tests/mock_server/scenarios/tecno_km4_ozon_0982_fetch_profile.json @@ -1,6 +1,6 @@ { "bank_name": "ozon", - "material_id": "7e3a7e4e-0d56-4d66-ac44-2b240ec33620", + "material_id": "19d1a302-7155-4d68-b596-5c7fb7cf6c2e", "type": "FETCH_PROFILE", "body": {} }