#include "PayByCardScript.h" #include #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "dump/TaskDumpRecorder.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" #include "dao/DeviceDAO.h" #include "dao/TransactionDAO.h" #include "time/DateUtils.h" 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(pngData.constData()), static_cast(pngData.size())); if (!pix) { qWarning() << "[OCR] Failed to read image"; api->End(); delete api; return {}; } api->SetImage(pix); char *text = api->GetUTF8Text(); QString result = QString::fromUtf8(text).trimmed(); delete[] text; pixDestroy(&pix); api->End(); delete api; return result; } static QString extractTransactionId(const QString &ocrText) { // "ID операции B606513042211200000120011700501" // "ID операции po-3140eb01-002c-5000-8000-033396b637e1" // или разбит на 2 строки QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-яЁё0-9\-]+(?:[^\S\n]+[A-Za-zА-Яа-яЁё0-9\-]+)?))"); if (auto m = reId.match(ocrText); m.hasMatch()) { QString id = m.captured(1); id.remove(' '); id.remove(QChar(0x00A0)); return id; } // "№ Ф-2026-25235663" QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))"); if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1); return {}; } namespace Ozon { PayByCardScript::PayByCardScript( MaterialInfo account, QString cardNumber, const 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 QString &adbId = device.adbSerial; const TaskDumpMeta dumpMeta{m_account.materialId, m_amount, {}, m_cardNumber, app.bankProfileId, app.phone}; TaskDumpRecorder::Scope dumpScope("ozon", "CREATE_TRANSACTION_CARD", adbId, dumpMeta, &m_error); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); // Закрываем рекламный bottom sheet if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) { qDebug() << "[PayByCard] Ad banner detected, closing..."; AdbUtils::makeTap(adbId, adNode.x(), adNode.y()); QThread::msleep(1000); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); } // Если не на домашнем экране — перезапускаем if (!xmlScreenParser.isHomeScreen()) { qDebug() << "[PayByCard] Not on home screen, restarting app..."; if (!runAppAndGoToHomeScreen(adbId, app.package, app.pinCode, device.screenWidth, device.screenHeight)) { m_error = "Cannot reach home screen: " + device.name + " (" + device.id + ")"; qWarning() << m_error; AdbUtils::tryToKillApplication(adbId, app.package); emit finishedWithResult(m_error); return; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); } // Закрываем баннеры tryToCloseAllBannersOnHomePage(adbId, device.screenWidth, device.screenHeight); QThread::msleep(500); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); // Закрываем баннер Google Play "Доступно обновление" if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(device.screenWidth, device.screenHeight); !gpNode.isEmpty()) { qDebug() << "[PayByCard] Google Play update banner detected, closing..."; AdbUtils::makeTap(adbId, gpNode.x(), gpNode.y()); QThread::msleep(1500); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); } // Закрываем баннер "Заказать карту" — тапаем сверху экрана if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) { qDebug() << "[PayByCard] 'Заказать карту' banner detected, closing..."; AdbUtils::makeTap(adbId, device.screenWidth / 2, device.screenHeight / 20); QThread::msleep(1000); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(adbId, ++m_counter)); } makePayment(device.adbSerial, app.package, app.pinCode, app.phone, device.screenWidth, device.screenHeight); emit finishedWithResult(m_error); } void PayByCardScript::makePayment( const QString &deviceId, const QString &packageName, const QString &pinCode, const QString &bankProfilePhone, const int width, const int height ) { auto checkApp = [&]() -> bool { if (!isAppRunning(deviceId, packageName)) { m_error = "Приложение было закрыто"; qWarning() << "[PayByCard]" << m_error; return false; } return true; }; // 1. Нажимаем "Перевести" на домашнем экране Node transferBtn = xmlScreenParser.findButtonNode("Перевести", false); if (transferBtn.isEmpty()) { m_error = "Кнопка 'Перевести' не найдена на домашнем экране"; qWarning() << "[PayByCard]" << m_error; return; } qDebug() << "[PayByCard] Tapping 'Перевести'..."; AdbUtils::makeTap(deviceId, transferBtn.x(), transferBtn.y()); QThread::msleep(2000); // 2. Ждём экран "Платежи и переводы" И прогрузки кнопки "По номеру карты" Node cardBtn; bool paymentsPageLoaded = false; bool titleSeen = false; { 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); } } if (!paymentsPageLoaded) { m_error = titleSeen ? "Кнопка 'По номеру карты' не найдена на экране платежей" : "Экран 'Платежи и переводы' не загрузился"; qWarning() << "[PayByCard]" << m_error; return; } // 3. Нажимаем "По номеру карты" в секции "ДРУГИЕ СПОСОБЫ" qDebug() << "[PayByCard] Tapping 'По номеру карты'..."; AdbUtils::makeTap(deviceId, cardBtn.x(), cardBtn.y()); QThread::msleep(2000); // 4. Ждём экран "Перевод по номеру карты" bool cardScreenLoaded = false; { 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); } } if (!cardScreenLoaded) { m_error = "Экран 'Перевод по номеру карты' не загрузился"; qWarning() << "[PayByCard]" << m_error; return; } qDebug() << "[PayByCard] Card payment screen loaded successfully"; // 5. Вводим номер карты в поле "Номер карты получателя" Node cardInput; for (int attempt = 0; attempt < 10; ++attempt) { if (!checkApp()) return; cardInput = xmlScreenParser.findEditTextByHint("Номер карты получателя"); if (cardInput.isEmpty()) { // На части сборок Ozon WebView hint у поля PAN пустой, а лейбла // "Номер карты получателя" на экране нет (есть только "КУДА"). // Поле PAN всегда внутри элемента resource-id="movable-pan-iframe". cardInput = xmlScreenParser.findPanCardNumberInput(); } if (!cardInput.isEmpty()) break; qDebug() << "[PayByCard] Waiting for 'Номер карты получателя' input, attempt" << attempt + 1 << "/ 10"; QThread::msleep(1500); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(deviceId, width, height); } if (cardInput.isEmpty()) { m_error = "Поле 'Номер карты получателя' не найдено"; qWarning() << "[PayByCard]" << m_error; return; } AdbUtils::makeTap(deviceId, cardInput.x(), cardInput.y()); QThread::msleep(500); 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) { if (!checkApp()) return; xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(deviceId, width, height); sumInput = xmlScreenParser.findEditTextByHint("Сумма от"); if (!sumInput.isEmpty()) break; // Fallback для устройств, где Ozon WebView не отдаёт hint (TECNO). // На экране "Перевод по номеру карты" всегда 2 EditText в DOM-порядке: // карта (сверху), сумма (снизу). Оба имеют resource-id="input___xxxx", // поэтому отличать по rid нельзя — берём второй по порядку обхода XML. QList editTexts; for (const Node &n : xmlScreenParser.findAllNodes()) { if (n.className != "android.widget.EditText") continue; if (n.height() <= 0 || n.height() > height * 4 / 5) continue; editTexts.append(n); } if (editTexts.size() >= 2) { sumInput = editTexts[1]; } if (!sumInput.isEmpty()) break; qDebug() << "[PayByCard] Waiting for 'Сумма от' input, attempt" << attempt + 1 << "/ 10"; QThread::msleep(1500); } if (sumInput.isEmpty()) { m_error = "Поле ввода суммы не найдено"; qWarning() << "[PayByCard]" << m_error; return; } AdbUtils::makeTap(deviceId, sumInput.x(), sumInput.y()); QThread::msleep(500); AdbUtils::inputText(deviceId, QString::number(static_cast(m_amount))); QThread::msleep(1000); qDebug() << "[PayByCard] Card number and amount entered"; // Закрываем системную софт-клавиатуру — иначе она перекрывает кнопку "Перевести" // внизу экрана (bounds схлопываются до нулевой высоты). AdbUtils::hideKeyboard(deviceId); QThread::msleep(800); // 7. Ждём расчёта комиссии (текст после "Комиссия" перестаёт быть "Рассчитаем...") double fee = 0; bool feeCalculated = false; for (int attempt = 0; attempt < 15; ++attempt) { if (!checkApp()) return; xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); closeGooglePlayBannerIfVisible(deviceId, width, height); bool foundKomissiya = false; for (const Node &node : xmlScreenParser.findAllNodes()) { if (node.className == "android.widget.TextView" && node.text.trimmed() == "Комиссия") { foundKomissiya = true; continue; } if (foundKomissiya && node.className == "android.widget.TextView" && !node.text.isEmpty()) { const QString feeText = node.text.trimmed(); if (feeText.startsWith("Рассчитаем")) { foundKomissiya = false; break; // ещё не рассчитана } // Парсим "45 ₽" → 45.0 QString digits = feeText; digits.remove(QRegularExpression("[^0-9,\\.]")); digits.replace(',', '.'); fee = digits.toDouble(); feeCalculated = true; qDebug() << "[PayByCard] Fee parsed:" << fee << "from text:" << feeText; break; } } if (feeCalculated) break; qDebug() << "[PayByCard] Waiting for fee calculation, attempt" << attempt + 1 << "/ 15"; QThread::msleep(1500); } if (!feeCalculated) { qWarning() << "[PayByCard] Fee not calculated after timeout, proceeding with fee=0"; } // 8. Нажимаем "Перевести" (или "Перевести X ₽") // Ретраим, пока bounds кнопки не будут ненулевыми — клавиатура могла всплыть // заново (напр. если расчёт комиссии сфокусировал поле суммы). Node transferBtn2; for (int attempt = 0; attempt < 5; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); transferBtn2 = xmlScreenParser.findButtonNode("Перевести", true); if (!transferBtn2.isEmpty() && transferBtn2.height() > 0) break; qDebug() << "[PayByCard] Waiting for tappable 'Перевести', attempt" << attempt + 1 << "/ 5" << "(bounds collapsed — клавиатура ещё видна?)"; AdbUtils::hideKeyboard(deviceId); QThread::msleep(1000); } if (transferBtn2.isEmpty() || transferBtn2.height() == 0) { m_error = "Кнопка 'Перевести' не найдена или перекрыта клавиатурой"; qWarning() << "[PayByCard]" << m_error; return; } qDebug() << "[PayByCard] Tapping transfer button:" << transferBtn2.text; AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y()); QThread::msleep(3000); // 8.5. Проверяем экран ввода PIN-кода xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); if (const QList pinNodes = xmlScreenParser.tryToFindPinCode(pinCode, "Введите ПИН"); !pinNodes.isEmpty()) { qDebug() << "[PayByCard] PIN code screen detected after confirm, entering PIN..."; inputPinCode(deviceId, pinNodes); QThread::msleep(3000); } // 9. Ждём результат: "Успешно" или "Не удалось" FreezeDetector resultFreeze; for (int attempt = 0; attempt < 15; ++attempt) { if (!checkApp()) return; 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, "Введите ПИН"); !pinNodes.isEmpty()) { qDebug() << "[PayByCard] PIN code screen detected (retry), entering PIN..."; inputPinCode(deviceId, pinNodes); QThread::msleep(3000); continue; } if (!xmlScreenParser.findTextNode("Успешно").isEmpty()) { qDebug() << "[PayByCard] Payment successful!"; m_error.clear(); // Сохраняем транзакцию в БД TransactionInfo tx; tx.accountId = m_account.id; tx.amount = -m_amount; tx.fee = fee; tx.phone = m_cardNumber; tx.bankName = "ozon"; tx.type = TransactionType::Card; tx.status = TransactionStatus::Complete; tx.timestamp = DateUtils::getUtcNow(); tx.completeTime = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow")); tx.bankTime = tx.completeTime.toString("dd.MM.yyyy, HH:mm"); const int txId = TransactionDAO::insertTransaction(tx); qDebug() << "[PayByCard] Saved transaction to DB, id:" << txId << "fee:" << fee; // Скриншот экрана "Успешно" — отдаём как receipt-image и в EventHandler. // Раньше дополнительно тапали "Документы" + ждали "Поделиться" + OCR // ради bank_transaction_id, но это медленно (до 30 сек ожидания) и // нестабильно. Теперь полагаемся на fallback-ID. QString externalId; 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 (externalId.isEmpty() && txId != -1) { externalId = "ozon_" + bankProfilePhone + "_" + QString::number(QDateTime::currentSecsSinceEpoch()); qDebug() << "[PayByCard] Generated fallback transaction ID:" << externalId; } if (!externalId.isEmpty() && txId != -1) { TransactionDAO::updateBankTrExternalId(txId, externalId); qDebug() << "[PayByCard] Transaction ID:" << externalId; } AdbUtils::goBack(deviceId); QThread::msleep(1000); AdbUtils::goBack(deviceId); QThread::msleep(1000); return; } if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) { m_error = "Перевод не удался (экран 'Не удалось')"; qWarning() << "[PayByCard]" << m_error; AdbUtils::goBack(deviceId); QThread::msleep(1000); AdbUtils::goBack(deviceId); QThread::msleep(1000); return; } qDebug() << "[PayByCard] Waiting for payment result, attempt" << attempt + 1 << "/ 15"; QThread::msleep(2000); } m_error = "Не дождались результата перевода"; qWarning() << "[PayByCard]" << m_error; } } // namespace Ozon