1
0
forked from BRT/arc
arc/android/dushanbe/PayByCardScript.cpp

639 lines
30 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 "PayByCardScript.h"
#include <QRegularExpression>
#include <QThread>
#include <tesseract/baseapi.h>
#include <leptonica/allheaders.h>
#include "adb/AdbUtils.h"
#include "dump/TaskDumpRecorder.h"
#include "ocr/OcrUtils.h"
#include "dao/BankProfileDAO.h"
#include "dao/DeviceDAO.h"
#include "dao/TransactionDAO.h"
#include "AppLogger.h"
#include <QCoreApplication>
// Точечный OCR: находит строку "операции" и кропает правую часть для digit-only OCR
static QString recognizeOperationId(const QByteArray &pngData) {
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(pngData.constData()),
static_cast<size_t>(pngData.size()));
if (!pix) return {};
const int w = pixGetWidth(pix);
const int h = pixGetHeight(pix);
auto *api = new tesseract::TessBaseAPI();
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus+eng")) {
pixDestroy(&pix); delete api;
return {};
}
api->SetImage(pix);
api->Recognize(nullptr);
int opLineY = -1, opLineH = 0;
bool prevWasNomer = false;
tesseract::ResultIterator *ri = api->GetIterator();
if (ri) {
do {
const char *word = ri->GetUTF8Text(tesseract::RIL_WORD);
if (word) {
const QString w_str = QString::fromUtf8(word);
delete[] word;
// Ищем "Номер" (или OCR-артефакты: "Homep", "Номep")
if (w_str.contains(QString::fromUtf8("омер")) ||
w_str.contains(QString::fromUtf8("omep"))) {
prevWasNomer = true;
continue;
}
// После "Номер" ищем "операции"
if (prevWasNomer &&
(w_str.contains(QString::fromUtf8("операции")) ||
w_str.contains(QString::fromUtf8("onepaции")))) {
int x1, y1, x2, y2;
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
opLineY = y1;
opLineH = y2 - y1;
break;
}
prevWasNomer = false;
}
} while (ri->Next(tesseract::RIL_WORD));
}
api->End();
delete api;
if (opLineY < 0) { pixDestroy(&pix); return {}; }
const int cropY = qMax(0, opLineY - opLineH / 2);
const int cropH = opLineH * 2;
BOX *box = boxCreate(w / 2, cropY, w / 2, qMin(cropH, h - cropY));
Pix *cropped = pixClipRectangle(pix, box, nullptr);
boxDestroy(&box);
pixDestroy(&pix);
if (!cropped) return {};
auto *api2 = new tesseract::TessBaseAPI();
if (api2->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "eng")) {
pixDestroy(&cropped); delete api2;
return {};
}
api2->SetVariable("tessedit_char_whitelist", "0123456789");
api2->SetPageSegMode(tesseract::PSM_SINGLE_LINE);
api2->SetImage(cropped);
char *text = api2->GetUTF8Text();
QString result = QString::fromUtf8(text).trimmed();
delete[] text;
pixDestroy(&cropped);
api2->End();
delete api2;
result.remove(QRegularExpression("[^0-9]"));
if (result.length() >= 8) {
qDebug() << "[Dushanbe::OCR] Precise operation ID:" << result;
return result;
}
return {};
}
// Координаты кнопки "Подтверждаю" на bottom sheet подтверждения платежа.
// Compose flatten-ит весь sheet в одну accessibility-ноду — отдельного узла
// кнопки в дампе нет, поэтому позицию ищем по скриншоту тремя способами по
// убыванию надёжности:
// 1) цвет — оранжевая sticky-кнопка RGB(255,117,40); не зависит от текста,
// контраста, языка и разрешения (самый стабильный путь);
// 2) OCR "Подтверждаю" — если вдруг тема/цвет кнопки иные;
// 3) геометрия — центр кнопки ≈ 6% высоты экрана над низом sheet'a; считаем
// относительно высоты, а не в абсолютных пикселях, чтобы работать на
// любом разрешении.
// `confirmNode` — нода sheet'a (на весь экран), `height` — высота экрана.
static QPoint locateConfirmButton(const QString &deviceId,
const Node &confirmNode,
int height) {
// Окно поиска — нижняя половина sheet'a, где живёт кнопка. Sheet-нода
// растянута на весь экран, поэтому y1/y2 сами по себе поиск не сужают.
const int yMin = (confirmNode.y1() + confirmNode.y2()) / 2;
const int yMax = confirmNode.y2();
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
// 1) Цвет.
QPoint tap = OcrUtils::findColorButtonCenter(screen, 255, 117, 40, 55, yMin, yMax);
if (tap.x() >= 0) {
qDebug() << "[Dushanbe::PayByCard] confirm button via color at" << tap;
return tap;
}
// 2) OCR.
tap = OcrUtils::findTextCenter(screen, QString::fromUtf8("Подтверждаю"), yMin, yMax);
if (tap.x() >= 0) {
qDebug() << "[Dushanbe::PayByCard] confirm button via OCR at" << tap;
return tap;
}
// 3) Геометрия: кнопка у самого низа sheet'a. Целимся ВНИЗ к y2, а не вверх.
const int sheetH = confirmNode.y2() - confirmNode.y1();
const int offset = qMax(sheetH * 6 / 100, height / 16);
tap = QPoint(confirmNode.x(), confirmNode.y2() - offset);
qDebug() << "[Dushanbe::PayByCard] confirm button geometry fallback at" << tap;
return tap;
}
namespace Dushanbe {
PayByCardScript::PayByCardScript(
MaterialInfo account,
QString cardNumber,
double amount,
QObject *parent
) : CommonScript(parent),
m_account(std::move(account)),
m_cardNumber(std::move(cardNumber)),
m_amount(amount) {
}
PayByCardScript::~PayByCardScript() = default;
void PayByCardScript::doStart() {
const DeviceInfo device = DeviceDAO::getDeviceById(m_account.deviceId);
const BankProfileInfo app = BankProfileDAO::getApplication(m_account.deviceId, m_account.appCode);
if (device.id.isEmpty() || app.id == -1) {
m_error = "Device or app not found: " + m_account.deviceId;
qCritical() << m_error;
emit finishedWithResult(m_error);
return;
}
const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber, app.bankProfileId, app.phone, device.id, app.appVersion};
TaskDumpRecorder::Scope dumpScope("dushanbe", "CREATE_TRANSACTION_CARD", device.adbSerial, dumpMeta, &m_error);
// Версионный трек приложения → версионно-зависимый парсинг (см. AppVersionTrack.h).
xmlScreenParser.setVersionTrack(Dushanbe::trackForVersion(app.appVersion));
qDebug() << "[Dushanbe::PayByCard] appVersion=" << app.appVersion
<< "→ track" << Dushanbe::trackName(xmlScreenParser.versionTrack());
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
if (!xmlScreenParser.isHomeScreen()) {
qDebug() << "[Dushanbe::PayByCard] Not on home screen, restarting app...";
if (!runAppAndGoToHomeScreen(device.adbSerial, app.package, app.pinCode,
device.screenWidth, device.screenHeight)) {
m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")";
qWarning() << m_error;
AppLogger::log("dushanbe/PayByCard", device.id, m_error, {}, "CREATE_TRANSACTION");
AdbUtils::tryToKillApplication(device.adbSerial, app.package);
emit finishedWithResult(m_error);
return;
}
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter));
}
makePayment(device.adbSerial, app.pinCode, app.phone, device.screenWidth, device.screenHeight);
emit finishedWithResult(m_error);
}
void PayByCardScript::makePayment(
const QString &deviceId,
const QString &pinCode,
const QString &bankProfilePhone,
const int width,
const int height
) {
// 1. Тапаем "Переводы" в нижней навигации
Node transfersTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Переводы"));
if (transfersTab.isEmpty()) {
m_error = "Tab 'Переводы' not found on home screen";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Переводы' tab";
AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y());
QThread::msleep(2000);
// 2. Ждём экран переводов — ищем "DC (по номеру карты)"
bool transfersLoaded = false;
Node cardTransferBtn;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
cardTransferBtn = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("DC (по номеру карты)"), true);
if (!cardTransferBtn.isEmpty()) {
transfersLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for transfers screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!transfersLoaded) {
m_error = "Transfers screen not loaded or 'DC (по номеру карты)' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 3. Тапаем "DC (по номеру карты)"
qDebug() << "[Dushanbe::PayByCard] Tapping 'DC (по номеру карты)'";
AdbUtils::makeTap(deviceId, cardTransferBtn.x(), cardTransferBtn.y());
QThread::msleep(2000);
// 4. Ждём экран ввода — ищем EditText с hint "Номер карты получателя"
bool inputScreenLoaded = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
Node editText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер карты получателя"));
if (editText.isEmpty()) {
editText = xmlScreenParser.findFirstEditText();
}
if (!editText.isEmpty()) {
inputScreenLoaded = true;
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for card input screen, attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!inputScreenLoaded) {
m_error = "Card input screen not loaded";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 5. Вводим номер карты в поле "Номер карты получателя"
Node cardEditText = xmlScreenParser.findEditTextByHint(
QString::fromUtf8("Номер карты получателя"));
if (cardEditText.isEmpty()) {
cardEditText = xmlScreenParser.findFirstEditText();
}
AdbUtils::makeTap(deviceId, cardEditText.x(), cardEditText.y());
QThread::msleep(500);
AdbUtils::inputText(deviceId, m_cardNumber);
QThread::msleep(1000);
// Скрываем клавиатуру чтобы поле суммы стало видимым
AdbUtils::goBack(deviceId);
QThread::msleep(1000);
// 6. Вводим сумму в поле "Сумма" (hint содержит "Сумма")
Node sumEditText;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
if (!sumEditText.isEmpty()) break;
qDebug() << "[Dushanbe::PayByCard] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500);
}
if (sumEditText.isEmpty()) {
m_error = "Sum input field not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y());
QThread::msleep(500);
// Сумма приходит с копейками (например 100.50), форматируем как целое или с точкой
const QString amountStr = (m_amount == static_cast<int>(m_amount))
? QString::number(static_cast<int>(m_amount))
: QString::number(m_amount, 'f', 2);
AdbUtils::inputText(deviceId, amountStr);
QThread::msleep(1000);
// 6a. Скрываем клавиатуру и выбираем карту-источник (под полем "Комментарий")
AdbUtils::goBack(deviceId);
QThread::msleep(500);
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
{
const QString cardMask = "****" + m_account.lastNumbers;
Node cardNode;
for (const Node &n : xmlScreenParser.findAllNodes()) {
if (!n.clickable) continue;
if (n.contentDesc.contains(cardMask)) { cardNode = n; break; }
}
if (cardNode.isEmpty()) {
m_error = "Source card not found: " + cardMask;
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Selecting source card:" << cardMask;
AdbUtils::makeTap(deviceId, cardNode.x(), cardNode.y());
QThread::msleep(500);
}
// 7. Нажимаем "Далее"
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
Node daleeBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Далее"));
if (daleeBtn.isEmpty()) {
daleeBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Далее"), false);
}
if (daleeBtn.isEmpty()) {
m_error = "Button 'Далее' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Tapping 'Далее'";
AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y());
QThread::msleep(2000);
// 8. После "Далее" возможны три исхода:
// a) кнопка меняется на "Оплатить" → обычный поток (8 → 9 → 10);
// b) платёж проходит мгновенно, минуя bottom sheet → сразу "Платеж выполнен";
// c) экран ошибки.
// earlySuccess=true означает b — шаги 9 и 10 пропускаем, идём сразу к парсингу чека.
bool oplatitFound = false;
bool earlySuccess = false;
for (int attempt = 0; attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Payment completed immediately after 'Далее'";
oplatitFound = true;
earlySuccess = true;
break;
}
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected after 'Далее'";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (oplatitBtn.isEmpty()) {
oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!oplatitBtn.isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Tapping 'Оплатить'";
AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y());
oplatitFound = true;
QThread::msleep(3000);
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for 'Оплатить', attempt" << attempt + 1;
QThread::msleep(1500);
}
if (!oplatitFound) {
m_error = "Button 'Оплатить' not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю"
// content-desc содержит "Подтверждение платежа...Подтверждаю"
// Кнопка "Подтверждаю" визуально внизу bottom sheet
bool confirmed = earlySuccess;
for (int attempt = 0; !earlySuccess && attempt < 10; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
// Пин-код может появиться
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByCard] PIN code screen, entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
// Ищем элемент с "Подтверждение платежа" и "Подтверждаю" в content-desc
Node confirmNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!confirmNode.isEmpty() &&
confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
// Позицию кнопки ищем: цвет → OCR → геометрия (см. locateConfirmButton).
const QPoint tap = locateConfirmButton(deviceId, confirmNode, height);
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
confirmed = true;
QThread::msleep(4000);
break;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for confirmation sheet, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!confirmed) {
m_error = "Confirmation sheet not found";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// 10. Ждём результат — "Платеж выполнен" или ошибку.
// Промежуточный статус "Выполняется" может висеть до ~1.5 мин, пока банк
// подтверждает перевод, поэтому общий таймаут — 60 итераций × 2 сек = 2 мин.
// Лимиты повторных тапов: если первый тап "Подтверждаю" не сработал,
// bottom sheet дисмиссится, виден "Оплатить" → перетапываем; следующий
// sheet → пробуем другую Y. Без лимита получали бесконечный
// "Оплатить ↔ Подтверждаю" со случайными попаданиями в системные экраны
// (security/launcher).
bool success = earlySuccess;
int confirmRetries = 0;
int oplatitRetries = 0;
const int maxConfirmRetries = 2;
const int maxOplatitRetries = 2;
for (int attempt = 0; !earlySuccess && attempt < 60; ++attempt) {
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height);
// Пин-код
if (xmlScreenParser.isPinCodeScreen()) {
qDebug() << "[Dushanbe::PayByCard] PIN code screen (retry), entering PIN...";
inputPinCode(deviceId, pinCode);
QThread::msleep(3000);
continue;
}
// Успех: content-desc начинается с "Платеж выполнен"
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true).isEmpty()) {
success = true;
break;
}
// Ошибка
if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Ошибка"), true).isEmpty() ||
!xmlScreenParser.findTextNode(QString::fromUtf8("Ошибка")).isEmpty()) {
m_error = "Transfer failed: error screen detected";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
// Промежуточный статус — банк ещё обрабатывает перевод
if (!xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Выполняется"), true).isEmpty()) {
qDebug() << "[Dushanbe::PayByCard] Status 'Выполняется', waiting for completion, attempt"
<< attempt + 1;
QThread::msleep(2000);
continue;
}
// Если bottom sheet "Подтверждение платежа" всё ещё виден — первый тап
// не сработал (мимо OCR-координат), повторяем его той же логикой.
Node retryConfirm = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Подтверждение платежа"), true);
if (!retryConfirm.isEmpty() &&
retryConfirm.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) {
if (confirmRetries >= maxConfirmRetries) {
m_error = "Confirm tap missed " + QString::number(confirmRetries)
+ " times (sheet still visible)";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++confirmRetries;
qDebug() << "[Dushanbe::PayByCard] Confirm sheet still visible, re-tap"
<< confirmRetries << "/" << maxConfirmRetries;
// Та же логика, что и на первом тапе: цвет → OCR → геометрия.
const QPoint tap = locateConfirmButton(deviceId, retryConfirm, height);
qDebug() << "[Dushanbe::PayByCard] Re-tapping 'Подтверждаю' at" << tap;
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
QThread::msleep(4000);
continue;
}
// Sheet "Подтверждение платежа" пропал, но снова виден input-экран
// (кнопка "Оплатить" вернулась) — значит тап по "Подтверждаю" промахнулся
// и Compose дисмиснул bottom sheet (clip на scrim/Закрыть/X). Перезапускаем
// цикл: тапаем "Оплатить" → следующая итерация увидит sheet и retryConfirm
// выше повторно тапнет "Подтверждаю".
Node retryOplatit = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить"));
if (retryOplatit.isEmpty()) {
retryOplatit = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false);
}
if (!retryOplatit.isEmpty() && retryOplatit.clickable) {
if (oplatitRetries >= maxOplatitRetries) {
m_error = "Sheet dismissed and re-tap exhausted ("
+ QString::number(oplatitRetries) + " retries)";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
++oplatitRetries;
qDebug() << "[Dushanbe::PayByCard] Sheet dismissed, re-tap 'Оплатить'"
<< oplatitRetries << "/" << maxOplatitRetries;
AdbUtils::makeTap(deviceId, retryOplatit.x(), retryOplatit.y());
QThread::msleep(3000);
continue;
}
qDebug() << "[Dushanbe::PayByCard] Waiting for result, attempt" << attempt + 1;
QThread::msleep(2000);
}
if (!success) {
m_error = "Payment result not received within timeout";
qWarning() << "[Dushanbe::PayByCard]" << m_error;
AppLogger::log("dushanbe/PayByCard", deviceId, m_error,
AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION");
return;
}
qDebug() << "[Dushanbe::PayByCard] Payment successful!";
m_error.clear();
// Скриншот чека — сохраняем как m_resultScreenshot и отправляем в мониторинг
m_resultScreenshot = AdbUtils::captureScreenshotBytes(deviceId);
const QByteArray &receiptImage = m_resultScreenshot;
if (!m_resultScreenshot.isEmpty())
emit screenshotReady(m_resultScreenshot);
// Парсим детали из экрана "Платеж выполнен".
// До 3.2.33 все поля шли одним content-desc с ключами "Дата:" / "Время:" / "Комиссия:".
// С 3.2.33 лейблы и значения — отдельные View-ноды в двух колонках.
QString bankTime;
double fee = 0;
Node resultNode = xmlScreenParser.findNodeByContentDesc(
QString::fromUtf8("Платеж выполнен"), true);
if (!resultNode.isEmpty()) {
const QStringList lines = resultNode.contentDesc.split('\n');
for (int i = 0; i < lines.size(); ++i) {
const QString line = lines[i].trimmed();
if (line.startsWith(QString::fromUtf8("Комиссия:")) && i + 1 < lines.size()) {
fee = lines[i + 1].trimmed().toDouble();
} else if (line.startsWith(QString::fromUtf8("Дата:")) && i + 1 < lines.size()) {
bankTime = lines[i + 1].trimmed();
} else if (line.startsWith(QString::fromUtf8("Время:")) && i + 1 < lines.size()) {
bankTime += " " + lines[i + 1].trimmed();
}
}
}
if (bankTime.isEmpty()) {
const QMap<QString, QString> pairs = xmlScreenParser.parseLabelValuePairs();
const QString d = pairs.value(QString::fromUtf8("Дата"));
const QString t = pairs.value(QString::fromUtf8("Время"));
if (!d.isEmpty() || !t.isEmpty()) {
bankTime = (d + " " + t).trimmed();
}
if (fee == 0) {
fee = pairs.value(QString::fromUtf8("Комиссия")).toDouble();
}
}
qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee;
// bank_transaction_id: phone аккаунта + unix timestamp (без перехода в историю).
// Уникальность за счёт <bankProfilePhone, секунда>.
const QString operationId = QString("dushanbe_") + bankProfilePhone + "_"
+ QString::number(QDateTime::currentSecsSinceEpoch());
qDebug() << "[Dushanbe::PayByCard] Generated operationId:" << operationId;
// Сохраняем транзакцию
TransactionInfo tx;
tx.accountId = m_account.id;
tx.amount = -m_amount;
tx.fee = fee;
tx.status = TransactionStatus::Complete;
tx.type = TransactionType::Card;
tx.phone = m_cardNumber;
tx.bankName = "dushanbe_city_bank";
tx.bankTime = bankTime;
tx.bankTrExternalId = operationId;
tx.timestamp = QDateTime::currentDateTimeUtc();
tx.completeTime = QDateTime::currentDateTimeUtc();
tx.receiptImage = receiptImage;
const int txId = TransactionDAO::insertTransaction(tx);
if (txId != -1) {
qDebug() << "[Dushanbe::PayByCard] Transaction saved, id:" << txId
<< "operationId:" << operationId;
if (!receiptImage.isEmpty()) {
TransactionDAO::updateReceiptImage(txId, receiptImage);
}
} else {
qWarning() << "[Dushanbe::PayByCard] Failed to save transaction";
}
}
} // namespace Dushanbe