1
0
forked from BRT/arc
arc/android/ozon/GetLastTransactionsScript.cpp
slava d59bf51416 Add closeGooglePlayBannerIfVisible to handle Google Play update banners:
- Ensure banners are closed during transaction fetching in `GetLastTransactionsScript`.
- Update XML parsing workflow to accommodate banner dismissal logic.
2026-04-18 18:52:28 +07:00

877 lines
40 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "GetLastTransactionsScript.h"
#include <QJsonDocument>
#include <QJsonObject>
#include <QProcess>
#include <QRegularExpression>
#include <QThread>
#include <QTimeZone>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dao/MaterialDAO.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "time/DateUtils.h"
#include "AppLogger.h"
#include <QCoreApplication>
static QString recognizeTextFromImage(const QByteArray &pngData) {
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
qWarning() << "[OCR] Failed to init Tesseract";
delete api;
return {};
}
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) {
qWarning() << "[OCR] Failed to read image";
api->End();
delete api;
return {};
}
Pix *gray = pixConvertRGBToGray(pix, 0.0f, 0.0f, 0.0f);
pixDestroy(&pix);
if (!gray) {
qWarning() << "[OCR] Failed to convert to grayscale";
api->End();
delete api;
return {};
}
api->SetImage(gray);
api->SetSourceResolution(300);
char *text = api->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&gray);
api->End();
delete api;
return result;
}
static QString extractTransactionId(const QString &ocrText) {
// ID может быть на одной строке: "ID операции B606513042211200000120011700501"
// или с дефисами: "ID операции po-3140eb01-002c-5000-8000-033396b637e1"
// или разбит на 2 строки: "ID ... po-3140eb01-002c-5000-8000-033396b\n ... 637e1"
QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-z0-9\-]+(?:[^\S\n]+[A-Za-z0-9\-]+)?))");
if (auto m = reId.match(ocrText); m.hasMatch()) {
QString id = m.captured(1);
id.remove(' ');
id.remove(QChar(0x00A0));
return id;
}
QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))");
if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1);
return {};
}
namespace Ozon {
GetLastTransactionsScript::GetLastTransactionsScript(
QString deviceId,
QString appCode,
int maxCount,
double searchAmount,
QDateTime searchTime,
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)) {
}
GetLastTransactionsScript::~GetLastTransactionsScript() = default;
void GetLastTransactionsScript::doStart() {
DeviceInfo device = DeviceDAO::getDeviceById(m_deviceId);
if (device.id.isEmpty()) device = DeviceDAO::findByAdbSerial(m_deviceId);
if (!device.adbSerial.isEmpty()) m_deviceId = device.adbSerial;
const BankProfileInfo app = BankProfileDAO::getApplication(device.id, m_appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_deviceId + " " + m_appCode;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
if (!navigateToTransactionList(device, app)) {
emit finishedWithResult(m_error);
return;
}
// Находим accountId
int accountId = -1;
const QList<MaterialInfo> accounts = MaterialDAO::getAccounts(device.id, m_appCode);
if (!accounts.isEmpty()) {
accountId = accounts.first().id;
} else {
qWarning() << "[GetLastTransactions] No account found for" << device.id << m_appCode;
}
// Выбор режима: поиск конкретной транзакции или сканирование последних
if (qAbs(m_searchAmount) > 0.01) {
searchTransaction(device, accountId);
} else {
scanRecentTransactions(device, accountId);
}
emit finishedWithResult(m_error);
}
bool GetLastTransactionsScript::navigateToTransactionList(const DeviceInfo &device, const BankProfileInfo &app) {
// Хелпер: если на экране ошибка загрузки ("Обновить"), тапаем один раз
auto tryRefreshIfError = [&]() {
// Кнопка "Обновить" — Button с content-desc="Обновить" (text пустой)
Node refreshBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Обновить"));
if (refreshBtn.x() > 0 && refreshBtn.y() > 0) {
qDebug() << "[GetLastTransactions] Page load error, tapping 'Обновить' at"
<< refreshBtn.x() << refreshBtn.y();
AdbUtils::makeTap(m_deviceId, refreshBtn.x(), refreshBtn.y());
QThread::msleep(5000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
}
};
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
// Закрываем рекламный bottom sheet
if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) {
AdbUtils::makeTap(m_deviceId, adNode.x(), adNode.y());
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (Node stillAd = xmlScreenParser.tryToFindAdBanner(); !stillAd.isEmpty()) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Если уже на экране операций — скроллим наверх и готово
if (xmlScreenParser.isTransactionListScreen()) {
qDebug() << "[GetLastTransactions] Already on transaction list screen, scrolling to top";
// Несколько быстрых свайпов вверх чтобы вернуться к началу списка
for (int i = 0; i < 5; ++i) {
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 200);
QThread::msleep(500);
}
QThread::msleep(1000);
return true;
}
// Если не на домашнем экране — перезапускаем
if (!xmlScreenParser.isHomeScreen()) {
if (!runAppAndGoToHomeScreen(m_deviceId, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + m_deviceId + ")";
qWarning() << m_error;
m_resultScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
AppLogger::log("ozon/GetLastTransactions", m_deviceId, m_error, {}, "FETCH_TRANSACTIONS");
AdbUtils::tryToKillApplication(m_deviceId, app.package);
return false;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
tryRefreshIfError();
}
// Закрываем баннеры на главной
tryToCloseAllBannersOnHomePage(m_deviceId, device.screenWidth, device.screenHeight);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
// Скроллим до "Все" и тапаем (быстрый fling чтобы пропустить баннеры)
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
Node allBtn;
for (int i = 0; i < 10; ++i) {
if (QThread::currentThread()->isInterruptionRequested()) return false;
const int x = device.screenWidth / 2;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - 200, x, 200, 100);
QThread::msleep(1500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
allBtn = xmlScreenParser.findButtonNode("Все", false);
if (!allBtn.isEmpty() && allBtn.y() > 0 && allBtn.y() < navBarTop) break;
allBtn = {};
}
if (allBtn.isEmpty()) {
m_error = "Button 'Все' not found: " + m_deviceId;
qWarning() << m_error;
return false;
}
AdbUtils::makeTap(m_deviceId, allBtn.x(), allBtn.y());
QThread::msleep(2000);
// Ждём экран "Операции"
for (int attempt = 0; attempt < 10; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) return false;
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (xmlScreenParser.isTransactionListScreen()) return true;
QThread::msleep(1500);
}
m_error = "Transaction list screen not loaded: " + m_deviceId;
qWarning() << m_error;
return false;
}
void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int accountId) {
// Скриншот списка транзакций — отправим при ошибке, удалим при успехе
const QByteArray listScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId);
// Время в Ozon показывается в таймзоне устройства, а не в MSK
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int maxScrolls = 10;
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
const QDateTime searchLocal = m_searchTime.toTimeZone(deviceTz);
const QDate searchDate = searchLocal.date();
qDebug() << "[FetchTransactions] Searching: device=" << m_deviceId
<< "amount=" << m_searchAmount
<< "time=" << m_searchTime.toString(Qt::ISODate)
<< "tz=" << tzName
<< "date(local)=" << searchDate.toString("dd.MM.yyyy")
<< "screen=" << device.screenWidth << "x" << device.screenHeight;
// ── Шаг 1: скроллим до нужной даты ──────────────────────────────
static const QMap<QString, int> monthMap = {
{"января", 1}, {"февраля", 2}, {"марта", 3}, {"апреля", 4},
{"мая", 5}, {"июня", 6}, {"июля", 7}, {"августа", 8},
{"сентября", 9}, {"октября", 10}, {"ноября", 11}, {"декабря", 12}
};
static const QRegularExpression dateRx(R"((\d{1,2})\s+(\S+),\s+\S+)");
auto parseDateHeader = [&](const QString &t) -> QDate {
const QDate today = QDateTime::currentDateTimeUtc().toTimeZone(deviceTz).date();
if (t.compare("Сегодня", Qt::CaseInsensitive) == 0) return today;
if (t.compare("Вчера", Qt::CaseInsensitive) == 0) return today.addDays(-1);
const auto match = dateRx.match(t);
if (!match.hasMatch()) return {};
const int day = match.captured(1).toInt();
const int month = monthMap.value(match.captured(2), 0);
if (month == 0) return {};
return QDate(searchDate.year(), month, day);
};
auto hasDateOnScreen = [&]() -> bool {
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
if (parseDateHeader(node.text.trimmed()) == searchDate) return true;
}
return false;
};
auto hasOlderDateOnScreen = [&]() -> bool {
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid() && d < searchDate) return true;
}
return false;
};
// Ждём пока WebView раскроет accessibility tree (иначе dump пустой)
for (int wait = 0; wait < 5; ++wait) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (xmlScreenParser.findTransactionButtons(1).size() > 0) break;
if (hasDateOnScreen()) break;
qDebug() << "[FetchTransactions] Waiting for WebView content..." << (wait + 1);
QThread::msleep(2000);
}
// Скроллим к нужной дате, запоминаем количество скроллов
int scrollsToDate = 0;
bool dateFound = false;
for (int scroll = 0; scroll < maxScrolls; ++scroll) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
if (hasDateOnScreen()) {
dateFound = true;
scrollsToDate = scroll;
qDebug() << "[FetchTransactions] Found target date on screen after" << scroll << "scrolls";
break;
}
if (hasOlderDateOnScreen()) {
qDebug() << "[FetchTransactions] Passed target date, not found in list";
break;
}
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight * 2 / 3, device.screenWidth / 2,
device.screenHeight / 3, 600);
QThread::msleep(1500);
}
if (!dateFound) {
m_error = "Date not found in transaction list: " + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
return;
}
// Хелпер: находит Y-диапазон [dateY, nextDateY) для нужной даты на экране.
// Кнопки транзакций между этими Y принадлежат нужному дню.
// Если заголовок нужной даты ушёл за верх экрана, но следующая дата ещё
// видна ниже (или не видна вовсе), dateY = 0 — все кнопки выше nextDateY
// принадлежат нашему дню.
auto findDateYRange = [&]() -> QPair<int, int> {
int dateY = -1;
int nextDateY = device.screenHeight;
bool foundOlderDate = false;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (!d.isValid()) continue;
if (d == searchDate) {
dateY = node.y();
} else if (d < searchDate) {
// Заголовок более старой даты — граница снизу
if (dateY >= 0 && node.y() > dateY) {
nextDateY = node.y();
} else if (dateY < 0) {
// Заголовок нужной даты ушёл вверх, но видна более старая дата
nextDateY = node.y();
dateY = 0; // всё выше nextDateY принадлежит нашему дню
}
foundOlderDate = true;
break;
}
}
// Заголовок ушёл вверх и нет более старых дат на экране —
// весь экран всё ещё внутри нашей даты
if (dateY < 0 && !foundOlderDate && dateFound) {
dateY = 0;
}
qDebug() << "[FetchTransactions] findDateYRange: dateY=" << dateY
<< "nextDateY=" << nextDateY << "foundOlderDate=" << foundOlderDate;
return {dateY, nextDateY};
};
// ── Шаг 2: тапаем только по кнопкам под заголовком нужной даты ───
QSet<QString> checkedDetails;
int noProgressCount = 0; // Счётчик итераций без прогресса (новых unchecked кнопок)
for (int attempt = 0; attempt < 15; ++attempt) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
// Логируем все заголовки дат на экране
{
QStringList datesOnScreen;
for (const Node &node : xmlScreenParser.getNodes()) {
if (node.className != "android.widget.TextView") continue;
const QDate d = parseDateHeader(node.text.trimmed());
if (d.isValid()) {
datesOnScreen << QString("%1@y=%2").arg(d.toString("dd.MM")).arg(node.y());
}
}
qDebug() << "[FetchTransactions] attempt=" << attempt << "dates on screen:" << datesOnScreen;
}
// Находим Y-диапазон заголовка нужной даты
const auto [dateHeaderY, nextDateHeaderY] = findDateYRange();
if (dateHeaderY < 0) {
qDebug() << "[FetchTransactions] Date completely off screen, scrolling up";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2,
device.screenHeight / 3,
device.screenWidth / 2,
device.screenHeight * 2 / 3, 600);
QThread::msleep(1500);
continue;
}
QList<Node> txButtons = xmlScreenParser.findTransactionButtons(20);
qDebug() << "[FetchTransactions] Found" << txButtons.size() << "buttons, dateY range:"
<< dateHeaderY << "-" << nextDateHeaderY;
bool tapped = false;
bool hasNewButton = false; // Есть ли новая (ещё не проверенная) кнопка с нужной суммой
int buttonsInRange = 0; // Кнопки внутри Y-диапазона нужной даты
for (int bi = 0; bi < txButtons.size(); ++bi) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
// Пропускаем кнопки вне Y-диапазона нужной даты
const int btnY = txButtons[bi].y();
if (btnY <= dateHeaderY || btnY >= nextDateHeaderY) continue;
if (btnY <= 0 || btnY >= navBarTop) continue;
++buttonsInRange;
const TransactionInfo parsed = xmlScreenParser.parseTransactionButtonText(txButtons[bi].text);
qDebug() << "[FetchTransactions] Button" << bi << "y=" << btnY
<< "parsed amount=" << parsed.amount << "search=" << m_searchAmount
<< "text=" << txButtons[bi].text.left(60);
if (qAbs(parsed.amount - m_searchAmount) > 0.01) continue;
const Node &visibleBtn = txButtons[bi];
qDebug() << "[FetchTransactions] Amount match:" << parsed.amount
<< "at" << visibleBtn.x() << visibleBtn.y()
<< "(dateY:" << dateHeaderY << "-" << nextDateHeaderY << ")";
// Тапаем и ждём деталей
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
QThread::msleep(2000);
TransactionInfo detail;
for (int da = 0; da < 10; ++da) {
if (QThread::currentThread()->isInterruptionRequested()) {
m_error = "Interrupted";
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
detail = xmlScreenParser.parseTransactionDetail();
if (!detail.bankTime.isEmpty()) break;
if (xmlScreenParser.isTransactionListScreen()) {
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
}
QThread::msleep(1500);
}
if (detail.bankTime.isEmpty()) {
qWarning() << "[FetchTransactions] Could not load detail, going back";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
// Парсим дату из деталей
QString dtStr = detail.bankTime;
for (int ci = 0; ci < dtStr.size(); ++ci) {
const QChar ch = dtStr.at(ci);
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
}
QDateTime txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
if (!txTime.isValid()) txTime = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
if (txTime.isValid()) txTime.setTimeZone(deviceTz);
// Пропускаем если уже проверяли эту транзакцию
if (checkedDetails.contains(dtStr)) {
qDebug() << "[FetchTransactions] Already checked:" << dtStr << "- skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(500);
continue;
}
checkedDetails.insert(dtStr);
tapped = true;
hasNewButton = true;
// Проверяем что транзакция принадлежит нужной дате (защита от широкого Y-диапазона)
if (txTime.isValid()) {
const QDate txLocalDate = txTime.toTimeZone(deviceTz).date();
if (txLocalDate != searchDate) {
qDebug() << "[FetchTransactions] Wrong date in detail:" << dtStr
<< "expected date" << searchDate.toString("dd.MM.yyyy")
<< "- button belongs to different date, skipping";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Проверяем время (±10 минут)
if (txTime.isValid() && m_searchTime.isValid()) {
const qint64 diffSecs = qAbs(txTime.toUTC().secsTo(m_searchTime.toUTC()));
if (diffSecs > 600) {
qDebug() << "[FetchTransactions] Time mismatch:" << dtStr
<< "expected" << searchLocal.toString("dd.MM.yyyy, HH:mm")
<< "diff=" << diffSecs << "sec";
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
continue;
}
}
// Нашли!
qDebug() << "[FetchTransactions] FOUND transaction: device=" << m_deviceId
<< "amount=" << parsed.amount << "time=" << dtStr
<< "name=" << detail.name;
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));
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) {
TransactionInfo tx;
tx.accountId = accountId;
tx.amount = parsed.amount;
tx.bankName = m_appCode;
tx.bankTime = dtStr;
tx.bankTrExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
if (txTime.isValid()) tx.completeTime = txTime;
if (!detail.name.isEmpty()) tx.name = detail.name;
if (!detail.description.isEmpty()) tx.description = detail.description;
if (!detail.phone.isEmpty()) {
QString cleanPhone;
for (const QChar &ch : detail.phone)
if (ch.isDigit()) cleanPhone.append(ch);
tx.phone = cleanPhone;
tx.type = TransactionType::Phone;
}
TransactionDAO::insertTransactionIfNotExists(tx);
}
// Отдаём результат в EventHandler через сигнал (как Dushanbe)
{
const QString finalExternalId = externalId.isEmpty()
? QString("ozon_") + QString(dtStr).remove(',').trimmed().replace(' ', '_')
: externalId;
QJsonObject txJson;
txJson["bank_transaction_id"] = finalExternalId;
txJson["amount"] = static_cast<int>(parsed.amount * 100);
txJson["currency"] = 643; // RUB
txJson["transaction_type"] = "bank";
QString participator = detail.phone;
if (participator.isEmpty()) participator = detail.name;
txJson["bank_participator_id"] = participator;
txJson["status"] = "completed";
QJsonObject extraObj;
extraObj["fee"] = 0;
txJson["extra"] = extraObj;
txJson["created_at"] = txTime.isValid()
? txTime.toUTC().toString(Qt::ISODate) : QJsonValue(QJsonValue::Null);
qDebug() << "[FetchTransactions] Emitting parsed tx:"
<< QJsonDocument(txJson).toJson(QJsonDocument::Compact);
emit transactionParsed(txJson);
}
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
return; // успех
}
// Отслеживаем прогресс: "без прогресса" только если были кнопки с СОВПАДАЮЩЕЙ
// суммой (tapped), но все уже checked. Если суммы не совпали — скроллим дальше,
// нужная кнопка может быть ниже.
if (!hasNewButton && tapped) {
++noProgressCount;
if (noProgressCount >= 5) {
qDebug() << "[FetchTransactions] No progress after" << noProgressCount
<< "attempts with matching amount — giving up";
break;
}
} else if (hasNewButton) {
noProgressCount = 0;
}
// Скроллим вниз если не нашли нужную транзакцию
if (!tapped || !hasNewButton) {
qDebug() << "[FetchTransactions] Scrolling down (tapped=" << tapped
<< "hasNew=" << hasNewButton << "inRange=" << buttonsInRange
<< "noProgress=" << noProgressCount << ")";
if (dateHeaderY > 0 && dateHeaderY < device.screenHeight / 2) {
// Заголовок в верхней половине — маленький скролл чтобы показать
// больше кнопок под этой датой
const int fromY = device.screenHeight * 2 / 3;
const int toY = device.screenHeight / 3;
qDebug() << "[FetchTransactions] Small scroll: from" << fromY << "to" << toY
<< "(dateHeaderY=" << dateHeaderY << ")";
AdbUtils::makeSwipe(m_deviceId, device.screenWidth / 2, fromY,
device.screenWidth / 2, toY, 500);
} 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);
}
QThread::msleep(1500);
}
}
m_error = "Transaction not found: device=" + m_deviceId
+ " amount=" + QString::number(m_searchAmount, 'f', 2)
+ " date=" + searchDate.toString("dd.MM.yyyy");
qWarning() << "[FetchTransactions]" << m_error;
// Отправляем скриншот списка транзакций, сделанный до начала поиска
m_resultScreenshot = listScreenshot.isEmpty()
? AdbUtils::captureScreenshotBytes(m_deviceId) : listScreenshot;
if (!m_resultScreenshot.isEmpty()) emit screenshotReady(m_resultScreenshot);
}
void GetLastTransactionsScript::scanRecentTransactions(const DeviceInfo &device, int accountId) {
const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId);
QTimeZone deviceTz(tzName.toUtf8());
if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow");
const int navBarTop = device.screenHeight - (device.screenHeight / 10);
// Ждём появления транзакций
QList<Node> txButtons;
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
txButtons = xmlScreenParser.findTransactionButtons(8);
if (!txButtons.isEmpty()) break;
QThread::msleep(1500);
}
qDebug() << "[GetLastTransactions] Found" << txButtons.size() << "transaction buttons";
QList<TransactionInfo> transactions;
for (int i = 0; i < txButtons.size(); ++i) {
const Node &btn = txButtons[i];
// Скроллим до кнопки если за экраном
if (btn.y() <= 0 || btn.y() >= navBarTop) {
for (int s = 0; s < 5; ++s) {
const int x = device.screenWidth / 2;
const int offset = device.screenHeight / 4;
AdbUtils::makeSwipe(m_deviceId, x, device.screenHeight - offset, x, offset);
QThread::msleep(1000);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
txButtons = xmlScreenParser.findTransactionButtons(8);
if (i < txButtons.size() && txButtons[i].y() > 0 && txButtons[i].y() < navBarTop) break;
}
if (i >= txButtons.size()) break;
}
const Node &visibleBtn = txButtons[i];
TransactionInfo tx = xmlScreenParser.parseTransactionButtonText(visibleBtn.text);
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
QThread::msleep(2000);
// Ждём загрузки деталей
TransactionInfo detail;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(m_deviceId, device.screenWidth, device.screenHeight);
detail = xmlScreenParser.parseTransactionDetail();
if (!detail.bankTime.isEmpty()) break;
if (xmlScreenParser.isTransactionListScreen()) {
AdbUtils::makeTap(m_deviceId, visibleBtn.x(), visibleBtn.y());
}
QThread::msleep(1500);
}
tx.bankName = m_appCode;
tx.bankTime = detail.bankTime;
tx.bankTrExternalId = detail.bankTrExternalId;
// Если ID не найден в XML — пробуем через "Документы" + OCR
if (tx.bankTrExternalId.isEmpty()) {
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 15; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
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()) {
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
tx.bankTrExternalId = extractTransactionId(ocrText);
qDebug() << "[GetLastTransactions] OCR extracted ID:" << tx.bankTrExternalId;
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
tx.status = TransactionStatus::Complete;
tx.timestamp = DateUtils::getUtcNow();
if (!detail.name.isEmpty()) tx.name = detail.name;
if (!detail.phone.isEmpty()) {
QString cleanPhone;
for (const QChar &ch : detail.phone)
if (ch.isDigit()) cleanPhone.append(ch);
tx.phone = cleanPhone;
tx.type = TransactionType::Phone;
}
// Парсим время
if (!tx.bankTime.isEmpty()) {
QString dtStr = tx.bankTime;
for (int ci = 0; ci < dtStr.size(); ++ci) {
const QChar ch = dtStr.at(ci);
if (ch != ' ' && ch.category() == QChar::Separator_Space) dtStr[ci] = ' ';
}
tx.bankTime = dtStr;
QDateTime parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy, HH:mm");
if (!parsed.isValid()) parsed = QDateTime::fromString(dtStr, "dd.MM.yyyy,HH:mm");
if (parsed.isValid()) {
parsed.setTimeZone(deviceTz);
tx.completeTime = parsed;
}
}
// Фильтр по времени (24ч) если нет maxCount
if (m_maxCount <= 0 && tx.completeTime.isValid()) {
const qint64 ageSecs = tx.completeTime.toUTC().secsTo(QDateTime::currentDateTimeUtc());
if (ageSecs > 24 * 3600) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
break;
}
}
// Сохраняем
if (accountId != -1) {
tx.accountId = accountId;
if (TransactionDAO::insertTransactionIfNotExists(tx)) {
// Скриншот чека
Node docBtn = xmlScreenParser.findButtonNode("Документы", false);
if (!docBtn.isEmpty()) {
AdbUtils::makeTap(m_deviceId, docBtn.x(), docBtn.y());
QThread::msleep(2000);
bool docLoaded = false;
for (int da = 0; da < 20; ++da) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (!xmlScreenParser.findNodeByContentDesc("Поделиться").isEmpty()) {
docLoaded = true; break;
}
QThread::msleep(1500);
}
if (docLoaded) {
QThread::msleep(500);
QProcess screencapProc;
screencapProc.start("adb", QStringList() << "-s" << m_deviceId << "exec-out" << "screencap" << "-p");
screencapProc.waitForFinished(5000);
const QByteArray screenshot = screencapProc.readAllStandardOutput();
if (!screenshot.isEmpty()) {
TransactionInfo saved = TransactionDAO::findByBankTimeAndAmount(
accountId, tx.bankName, tx.bankTime, tx.amount);
if (saved.id != -1) {
TransactionDAO::updateReceiptImage(saved.id, screenshot);
const QString ocrText = recognizeTextFromImage(screenshot);
if (!ocrText.isEmpty()) {
const QString txId = extractTransactionId(ocrText);
if (!txId.isEmpty()) {
TransactionDAO::updateBankTrExternalId(saved.id, txId);
}
}
}
}
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
}
}
}
transactions.append(tx);
if (m_maxCount > 0 && transactions.size() >= m_maxCount) {
AdbUtils::goBack(m_deviceId);
QThread::msleep(1000);
break;
}
AdbUtils::goBack(m_deviceId);
QThread::msleep(1500);
for (int attempt = 0; attempt < 5; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
if (xmlScreenParser.isTransactionListScreen()) break;
QThread::msleep(1000);
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter));
txButtons = xmlScreenParser.findTransactionButtons(8);
}
}
} // namespace Ozon