diff --git a/android/ozon/PayByCardScript.cpp b/android/ozon/PayByCardScript.cpp new file mode 100644 index 0000000..bbcf7b1 --- /dev/null +++ b/android/ozon/PayByCardScript.cpp @@ -0,0 +1,375 @@ +#include "PayByCardScript.h" + +#include +#include +#include +#include +#include + +#include +#include + +#include "adb/AdbUtils.h" +#include "dao/AccountDAO.h" +#include "dao/ApplicationDAO.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(nullptr, "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 операции A60130542076330K0000040011670301" или "ID операции = Аб60650..." + QRegularExpression reId(R"(ID\s+операции\s*=?\s*([A-Za-zА-Яа-я0-9]+))"); + if (auto m = reId.match(ocrText); m.hasMatch()) return m.captured(1); + + // "№ Ф-2026-25235663" + QRegularExpression reRef(R"(№\s*(Ф-[\d-]+))"); + if (auto m = reRef.match(ocrText); m.hasMatch()) return m.captured(1); + + return {}; +} + +namespace Ozon { + +PayByCardScript::PayByCardScript( + AccountInfo 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 ApplicationInfo app = ApplicationDAO::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; + } + + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + + // Закрываем рекламный bottom sheet + if (Node adNode = xmlScreenParser.tryToFindAdBanner(); !adNode.isEmpty()) { + qDebug() << "[PayByCard] Ad banner detected, closing..."; + AdbUtils::makeTap(device.id, adNode.x(), adNode.y()); + QThread::msleep(1000); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + } + + // Если не на домашнем экране — перезапускаем + if (!xmlScreenParser.isHomeScreen()) { + qDebug() << "[PayByCard] Not on home screen, restarting app..."; + if (!runAppAndGoToHomeScreen(device.id, app.package, app.pinCode, + device.screenWidth, device.screenHeight)) { + m_error = "Cannot reach home screen: " + device.id; + qWarning() << m_error; + AdbUtils::tryToKillApplication(device.id, app.package); + emit finishedWithResult(m_error); + return; + } + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + } + + // Закрываем баннеры + tryToCloseAllBannersOnHomePage(device.id, device.screenWidth, device.screenHeight); + QThread::msleep(500); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + + // Закрываем баннер Google Play "Доступно обновление" + if (Node gpNode = xmlScreenParser.tryToFindGooglePlayBanner(); !gpNode.isEmpty()) { + qDebug() << "[PayByCard] Google Play update banner detected, closing..."; + AdbUtils::makeTap(device.id, gpNode.x(), gpNode.y()); + QThread::msleep(1500); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + } + + // Закрываем баннер "Заказать карту" — тапаем сверху экрана + if (Node orderCardNode = xmlScreenParser.findTextNode("Заказать карту"); !orderCardNode.isEmpty()) { + qDebug() << "[PayByCard] 'Заказать карту' banner detected, closing..."; + AdbUtils::makeTap(device.id, device.screenWidth / 2, 100); + QThread::msleep(1000); + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.id, ++m_counter)); + } + + makePayment(device.id, app.pinCode, device.screenWidth, device.screenHeight); + + emit finishedWithResult(m_error); +} + +void PayByCardScript::makePayment( + const QString &deviceId, + const QString &pinCode, + const int width, + const int height +) { + // 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. Ждём экран "Платежи и переводы" + bool paymentsPageLoaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node title = xmlScreenParser.findTextNode("Платежи и переводы"); + if (!title.isEmpty()) { + paymentsPageLoaded = true; + break; + } + qDebug() << "[PayByCard] Waiting for 'Платежи и переводы', attempt" << attempt + 1 << "/ 10"; + QThread::msleep(1500); + } + + if (!paymentsPageLoaded) { + m_error = "Экран 'Платежи и переводы' не загрузился"; + qWarning() << "[PayByCard]" << m_error; + return; + } + + // 3. Нажимаем "По номеру карты" в секции "ДРУГИЕ СПОСОБЫ" + Node cardBtn = xmlScreenParser.findButtonNode("По номеру карты", false); + if (cardBtn.isEmpty()) { + m_error = "Кнопка 'По номеру карты' не найдена на экране платежей"; + qWarning() << "[PayByCard]" << m_error; + return; + } + + qDebug() << "[PayByCard] Tapping 'По номеру карты'..."; + AdbUtils::makeTap(deviceId, cardBtn.x(), cardBtn.y()); + QThread::msleep(2000); + + // 4. Ждём экран "Перевод по номеру карты" + bool cardScreenLoaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + 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 = xmlScreenParser.findEditTextByHint("Номер карты получателя"); + 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); + + // 6. Ждём распознавания карты и вводим сумму + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + Node sumInput = xmlScreenParser.findEditTextByHint("Сумма от"); + 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"; + + // 7. Ждём расчёта комиссии (текст после "Комиссия" перестаёт быть "Рассчитаем...") + double fee = 0; + bool feeCalculated = false; + for (int attempt = 0; attempt < 15; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + 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 ₽") + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node transferBtn2 = xmlScreenParser.findButtonNode("Перевести", true); + if (transferBtn2.isEmpty()) { + m_error = "Кнопка 'Перевести' не найдена на экране ввода суммы"; + qWarning() << "[PayByCard]" << m_error; + return; + } + + qDebug() << "[PayByCard] Tapping transfer button:" << transferBtn2.text; + AdbUtils::makeTap(deviceId, transferBtn2.x(), transferBtn2.y()); + QThread::msleep(3000); + + // 9. Ждём результат: "Успешно" или "Не удалось" + for (int attempt = 0; attempt < 15; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + 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; + + // Ищем кнопку "Документы" и достаём ID операции + 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 (docLoaded) { + QThread::msleep(500); + const QByteArray screenshot = AdbUtils::captureScreenshotBytes(deviceId); + if (!screenshot.isEmpty()) { + 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; + const QString externalId = extractTransactionId(ocrText); + if (!externalId.isEmpty()) { + TransactionDAO::updateBankTrExternalId(txId, externalId); + qDebug() << "[PayByCard] Extracted transaction ID:" << externalId; + } else { + qWarning() << "[PayByCard] Could not extract transaction ID from OCR text"; + } + } else { + qWarning() << "[PayByCard] OCR returned empty text"; + } + } else { + qWarning() << "[PayByCard] Screenshot is empty"; + } + } else { + qWarning() << "[PayByCard] 'Поделиться' not found, skipping receipt"; + } + + AdbUtils::goBack(deviceId); + QThread::msleep(1000); + } + + return; + } + + if (!xmlScreenParser.findTextNode("Не удалось").isEmpty()) { + m_error = "Перевод не удался (экран 'Не удалось')"; + qWarning() << "[PayByCard]" << m_error; + return; + } + + qDebug() << "[PayByCard] Waiting for payment result, attempt" << attempt + 1 << "/ 15"; + QThread::msleep(2000); + } + + m_error = "Не дождались результата перевода"; + qWarning() << "[PayByCard]" << m_error; +} + +} // namespace Ozon diff --git a/android/ozon/PayByCardScript.h b/android/ozon/PayByCardScript.h new file mode 100644 index 0000000..c613bae --- /dev/null +++ b/android/ozon/PayByCardScript.h @@ -0,0 +1,36 @@ +#pragma once +#include "CommonScript.h" + +namespace Ozon { + +class PayByCardScript final : public CommonScript { + Q_OBJECT + +public: + ~PayByCardScript() override; + + PayByCardScript( + AccountInfo account, + QString cardNumber, + double amount, + QObject *parent = nullptr + ); + +protected: + void doStart() override; + +private: + const AccountInfo m_account; + const QString m_cardNumber; + const double m_amount; + QString m_error; + + void makePayment( + const QString &deviceId, + const QString &pinCode, + int width, + int height + ); +}; + +} // namespace Ozon diff --git a/android/ozon/ScreenXmlParser.cpp b/android/ozon/ScreenXmlParser.cpp index 4480ab2..f0f5951 100644 --- a/android/ozon/ScreenXmlParser.cpp +++ b/android/ozon/ScreenXmlParser.cpp @@ -142,6 +142,15 @@ Node ScreenXmlParser::findFirstEditText() { return {}; } +Node ScreenXmlParser::findEditTextByHint(const QString &hintPrefix) { + for (const Node &node : m_nodes) { + if (node.className == "android.widget.EditText" && node.hint.contains(hintPrefix)) { + return node; + } + } + return {}; +} + Node ScreenXmlParser::findButtonNode(const QString &text, const bool contains) { for (const Node &node: m_nodes) { if (contains) { @@ -591,6 +600,9 @@ void ScreenXmlParser::parseAndSaveXml(const QString &xml) { if (reader.attributes().hasAttribute("resource-id")) { node.resourceId = reader.attributes().value("resource-id").toString(); } + if (reader.attributes().hasAttribute("hint")) { + node.hint = reader.attributes().value("hint").toString(); + } m_nodes.append(node); } diff --git a/android/ozon/ScreenXmlParser.h b/android/ozon/ScreenXmlParser.h index 8656493..23828fc 100644 --- a/android/ozon/ScreenXmlParser.h +++ b/android/ozon/ScreenXmlParser.h @@ -28,6 +28,8 @@ public: Node findFirstEditText(); + Node findEditTextByHint(const QString &hintPrefix); + Node findButtonNode(const QString &text, bool contains); QList findAllButtons(); diff --git a/assets/ozon/pay/pay_by_card.png b/assets/ozon/pay/pay_by_card.png new file mode 100644 index 0000000..f1c0c78 Binary files /dev/null and b/assets/ozon/pay/pay_by_card.png differ diff --git a/assets/ozon/pay/pay_by_card.xml b/assets/ozon/pay/pay_by_card.xml new file mode 100644 index 0000000..3e18cf8 --- /dev/null +++ b/assets/ozon/pay/pay_by_card.xml @@ -0,0 +1,820 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ozon/pay/pay_by_card_enter_sum.png b/assets/ozon/pay/pay_by_card_enter_sum.png new file mode 100644 index 0000000..cf5a218 Binary files /dev/null and b/assets/ozon/pay/pay_by_card_enter_sum.png differ diff --git a/assets/ozon/pay/pay_by_card_enter_sum.xml b/assets/ozon/pay/pay_by_card_enter_sum.xml new file mode 100644 index 0000000..21e36cf --- /dev/null +++ b/assets/ozon/pay/pay_by_card_enter_sum.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ozon/pay/pay_by_card_success.png b/assets/ozon/pay/pay_by_card_success.png new file mode 100644 index 0000000..4e1dce4 Binary files /dev/null and b/assets/ozon/pay/pay_by_card_success.png differ diff --git a/assets/ozon/pay/pay_by_card_success.xml b/assets/ozon/pay/pay_by_card_success.xml new file mode 100644 index 0000000..31214a4 --- /dev/null +++ b/assets/ozon/pay/pay_by_card_success.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/database/app_data.db b/database/app_data.db index 8ad645a..e7eeb21 100644 Binary files a/database/app_data.db and b/database/app_data.db differ diff --git a/models/android/xml/Node.h b/models/android/xml/Node.h index 044a3bb..bd2fca0 100644 --- a/models/android/xml/Node.h +++ b/models/android/xml/Node.h @@ -42,6 +42,7 @@ public: QString contentDesc; QString resourceId; QString className; + QString hint; bool clickable = false; bool focusable = false; diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index f06324d..d484852 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -29,6 +29,7 @@ #include "toss/LoginAndCheckAccountsTossScript.h" #include "ziraat/GetLastDaysHistoryZiraatScript.h" #include "ziraat/LoginAndCheckAccountsZiraatScript.h" +#include "ozon/PayByCardScript.h" #include "ozon/PayByPhoneScript.h" #include "rshb/PayByPhoneScript.h" @@ -273,7 +274,9 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, txObj["transaction_type"] = (tx.type == TransactionType::Phone) ? "top-up" : "bank"; txObj["bank_participator_id"]= tx.phone; txObj["status"] = tx.status == TransactionStatus::Complete ? "completed" : (tx.status == TransactionStatus::Wait ? "progress" : "completed"); - txObj["extra"] = QJsonObject(); + QJsonObject extraObj; + extraObj["fee"] = static_cast(tx.fee * 100); + txObj["extra"] = extraObj; if (tx.timestamp.isValid()) { txObj["created_at"] = tx.timestamp.toTimeZone(QTimeZone("Europe/Moscow")).toString(Qt::ISODateWithMs); } else if (!tx.bankTime.isEmpty()) { @@ -292,23 +295,33 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, } taskData = arr; } else if (event.type == EventType::CreateTransaction) { + // Определяем тип транзакции из JSON задачи + const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object(); + const QString txType = taskJson["body"].toObject()["transaction_type"].toString(); + QJsonObject obj; obj["amount"] = -static_cast(event.amount * 100); obj["currency"] = 643; - obj["transaction_type"] = "top-up"; + obj["transaction_type"] = txType.isEmpty() ? "top-up" : txType; obj["bank_participator_id"]= event.phone; obj["status"] = "completed"; obj["created_at"] = QDateTime::currentDateTime().toTimeZone(QTimeZone("Europe/Moscow")).toString(Qt::ISODateWithMs); - // Берём bank_transaction_id из последней сохранённой транзакции + // Берём bank_transaction_id и fee из последней сохранённой транзакции QList txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 1); + int feeInCents = 0; for (const auto &tx : txList) { if (!tx.bankTrExternalId.isEmpty() && qAbs(tx.amount) == event.amount && tx.phone == event.phone) { obj["bank_transaction_id"] = tx.bankTrExternalId; + feeInCents = static_cast(tx.fee * 100); break; } } + QJsonObject extraObj; + extraObj["fee"] = feeInCents; + obj["extra"] = extraObj; + taskData = obj; } else { QJsonObject obj; @@ -390,9 +403,21 @@ void EventHandler::startThreadForTask(const AccountInfo &account, const EventInf if (event.type == EventType::CreateTransaction) { const QString recipientBank = event.comment; // резолвленное имя банка-получателя - if (appCode == "ozon") setup(new Ozon::PayByPhoneScript(account, event.phone, recipientBank, event.amount)); - else if (appCode == "rshb") setup(new Rshb::PayByPhoneScript(account, event.phone, recipientBank, event.amount)); - else qWarning() << "[EventHandler] CREATE_TRANSACTION: unknown appCode:" << appCode; + + // Определяем тип транзакции из JSON задачи + const QJsonObject taskJson = QJsonDocument::fromJson(event.json.toUtf8()).object(); + const QString txType = taskJson["body"].toObject()["transaction_type"].toString(); + + if (txType == "bank") { + // Перевод по номеру карты + if (appCode == "ozon") setup(new Ozon::PayByCardScript(account, event.phone, event.amount)); + else qWarning() << "[EventHandler] CREATE_TRANSACTION bank: unknown appCode:" << appCode; + } else { + // Перевод по номеру телефона (top-up) + if (appCode == "ozon") setup(new Ozon::PayByPhoneScript(account, event.phone, recipientBank, event.amount)); + else if (appCode == "rshb") setup(new Rshb::PayByPhoneScript(account, event.phone, recipientBank, event.amount)); + else qWarning() << "[EventHandler] CREATE_TRANSACTION: unknown appCode:" << appCode; + } } // // Watchdog-таймер: 5 минут — убиваем зависший поток