diff --git a/android/black/GetCardInfoScript.cpp b/android/black/GetCardInfoScript.cpp index dec27db..62d183f 100644 --- a/android/black/GetCardInfoScript.cpp +++ b/android/black/GetCardInfoScript.cpp @@ -132,6 +132,7 @@ void GetCardInfoScript::doStart() { materialBody["bank_profile_id"] = updatedApp.bankProfileId; materialBody["card_number"] = savedAccount.cardNumber; materialBody["name"] = updatedApp.fullName; + materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled"; ns.setPayload(QJsonDocument(materialBody).toJson()); ns.addExtra("account_id", savedAccount.id); ns.postMaterial(); diff --git a/android/dushanbe/GetCardInfoScript.cpp b/android/dushanbe/GetCardInfoScript.cpp index a23b03a..a62577b 100644 --- a/android/dushanbe/GetCardInfoScript.cpp +++ b/android/dushanbe/GetCardInfoScript.cpp @@ -174,8 +174,9 @@ void GetCardInfoScript::doStart() { ns.setDeviceId(m_deviceId); QJsonObject materialBody; materialBody["bank_profile_id"] = updatedApp.bankProfileId; - materialBody["card_number"] = fullNumber; + materialBody["card_number"] = QString(fullNumber).remove(' '); materialBody["name"] = updatedApp.fullName; + materialBody["state"] = materialStatusToString(savedAccount.status) == "active" ? "enabled" : "disabled"; ns.setPayload(QJsonDocument(materialBody).toJson()); ns.addExtra("account_id", savedAccount.id); ns.postMaterial(); diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index 32ff207..f3a315f 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -1,8 +1,12 @@ #include "GetLastTransactionsScript.h" +#include #include #include +#include +#include + #include "adb/AdbUtils.h" #include "dao/MaterialDAO.h" #include "dao/BankProfileDAO.h" @@ -10,6 +14,215 @@ #include "dao/TransactionDAO.h" #include "AppLogger.h" +static QString recognizeTextFromImage(const QByteArray &pngData) { + auto *api = new tesseract::TessBaseAPI(); + if (api->Init(nullptr, "rus+eng")) { + qWarning() << "[Dushanbe::OCR] Failed to init Tesseract"; + delete api; + return {}; + } + Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), + static_cast(pngData.size())); + if (!pix) { + 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; +} + +// Точечный OCR: находит "Номер операции" через общий OCR, затем кропает строку правее +// и распознаёт только цифры. Работает с любым разрешением экрана. +static QString recognizeOperationId(const QByteArray &pngData) { + Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), + static_cast(pngData.size())); + if (!pix) return {}; + + const int w = pixGetWidth(pix); + const int h = pixGetHeight(pix); + + // 1. Полный OCR с координатами слов — ищем "операции" + auto *api = new tesseract::TessBaseAPI(); + if (api->Init(nullptr, "rus+eng")) { + pixDestroy(&pix); delete api; + return {}; + } + api->SetImage(pix); + api->Recognize(nullptr); + + int opLineY = -1; + int 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; + 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 {}; + } + + // 2. Кропаем правую половину этой строки и OCR только цифры + 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(nullptr, "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 {}; +} + +struct OcrTransactionDetail { + QString date; + QString time; + QString operationId; + QString provider; + QString senderAccount; + QString receiverAccount; + double amount = 0.0; + double fee = 0.0; + QString status; +}; + +static OcrTransactionDetail parseDetailFromOcr(const QString &ocrText) { + OcrTransactionDetail d; + const QStringList lines = ocrText.split('\n'); + + for (const QString &rawLine : lines) { + const QString line = rawLine.trimmed(); + + // "Дата операции: 05.04.2026" или "Дата операции:" на отдельной строке + if (line.contains(QString::fromUtf8("Дата операции"))) { + QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))"); + if (auto m = re.match(line); m.hasMatch()) d.date = m.captured(1); + } + // "Время операции: 18:19:54" + else if (line.contains(QString::fromUtf8("Время операции"))) { + QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))"); + if (auto m = re.match(line); m.hasMatch()) d.time = m.captured(1); + } + // "Номер операции: 1598279763" + else if (line.contains(QString::fromUtf8("омер операции"))) { + QRegularExpression re(R"((\d{5,}))"); + if (auto m = re.match(line); m.hasMatch()) d.operationId = m.captured(1); + } + // "Поставщик: DC (по номеру карты)" + else if (line.contains(QString::fromUtf8("Поставщик"))) { + QRegularExpression re(R"(Поставщик[:\s]*(.+))"); + if (auto m = re.match(line); m.hasMatch()) d.provider = m.captured(1).trimmed(); + } + // "Счет отправителя: 9762***3258" + else if (line.contains(QString::fromUtf8("Счет отправителя")) || + line.contains(QString::fromUtf8("чет отправителя"))) { + QRegularExpression re(R"((\d[\d*]+))"); + if (auto m = re.match(line); m.hasMatch()) d.senderAccount = m.captured(1); + } + // "Счет получателя: 9762000186263942" или "992111100664" + else if (line.contains(QString::fromUtf8("Счет получателя")) || + line.contains(QString::fromUtf8("чет получателя"))) { + QRegularExpression re(R"((\d{6,}))"); + if (auto m = re.match(line); m.hasMatch()) d.receiverAccount = m.captured(1); + } + // "Сумма операции: 1.00" + else if (line.contains(QString::fromUtf8("Сумма операции")) || + line.contains(QString::fromUtf8("умма операции"))) { + QRegularExpression re(R"((\d+[\.,]\d{2}))"); + if (auto m = re.match(line); m.hasMatch()) { + QString val = m.captured(1); + val.replace(',', '.'); + d.amount = val.toDouble(); + } + } + // "Комиссия: 0.00" + else if (line.contains(QString::fromUtf8("Комиссия")) || + line.contains(QString::fromUtf8("омиссия"))) { + QRegularExpression re(R"((\d+[\.,]\d{2}))"); + if (auto m = re.match(line); m.hasMatch()) { + QString val = m.captured(1); + val.replace(',', '.'); + d.fee = val.toDouble(); + } + } + // "Статус: Успешный" + else if (line.contains(QString::fromUtf8("Статус"))) { + if (line.contains(QString::fromUtf8("Успешн"))) { + d.status = QString::fromUtf8("Успешный"); + } + } + } + + // Fallback: если дата/время не найдены в "ключ: значение", ищем паттерном + if (d.date.isEmpty()) { + QRegularExpression re(R"((\d{2}\.\d{2}\.\d{4}))"); + if (auto m = re.match(ocrText); m.hasMatch()) d.date = m.captured(1); + } + if (d.time.isEmpty()) { + QRegularExpression re(R"((\d{2}:\d{2}:\d{2}))"); + auto it = re.globalMatch(ocrText); + QStringList times; + while (it.hasNext()) times.append(it.next().captured(1)); + if (times.size() >= 2) d.time = times[1]; + else if (times.size() == 1) d.time = times[0]; + } + + if (ocrText.contains(QString::fromUtf8("Успешн"))) d.status = QString::fromUtf8("Успешный"); + + return d; +} + namespace Dushanbe { GetLastTransactionsScript::GetLastTransactionsScript( @@ -117,7 +330,7 @@ void GetLastTransactionsScript::doStart() { // Ищем соответствующий node чтобы по нему тапнуть Node txNode; for (const Node &node : xmlScreenParser.findAllNodes()) { - if (node.className != "android.widget.ImageView" || !node.clickable) continue; + if (node.className != "android.widget.ImageView") continue; if (!node.contentDesc.contains("***")) continue; // Проверяем что содержит нужную сумму QString amountStr = QString::number(tx.amount, 'f', 2); @@ -133,13 +346,14 @@ void GetLastTransactionsScript::doStart() { } AdbUtils::makeTap(m_deviceId, txNode.x(), txNode.y()); - QThread::msleep(2000); + QThread::msleep(3000); - // Ждём экран деталей + // Ждём экран деталей — ищем кнопку "Назад" (признак экрана детали) bool detailLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); - if (xmlScreenParser.isTransactionDetailScreen()) { + if (!xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Назад")).isEmpty() || + !xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Поделиться")).isEmpty()) { detailLoaded = true; break; } @@ -153,7 +367,18 @@ void GetLastTransactionsScript::doStart() { continue; } - const auto detail = xmlScreenParser.parseTransactionDetail(); + // OCR скриншота деталей + const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(m_deviceId); + const QString ocrText = recognizeTextFromImage(detailScreenshot); + qDebug() << "[Dushanbe::GetLastTransactions] OCR result:" << ocrText; + + OcrTransactionDetail detail = parseDetailFromOcr(ocrText); + + // Точечный OCR для номера операции (кроп + только цифры) + const QString preciseOpId = recognizeOperationId(detailScreenshot); + if (!preciseOpId.isEmpty()) { + detail.operationId = preciseOpId; + } // Проверяем время ±1 час if (m_searchTime.isValid() && !detail.date.isEmpty() && !detail.time.isEmpty()) { @@ -193,7 +418,7 @@ void GetLastTransactionsScript::doStart() { saveTx.description = detail.provider; saveTx.phone = detail.receiverAccount; saveTx.name = detail.receiverAccount; - saveTx.status = (detail.status == QString::fromUtf8("Успешный")) + saveTx.status = detail.status.contains(QString::fromUtf8("Успешн")) ? TransactionStatus::Complete : TransactionStatus::Unknown; saveTx.type = TransactionType::Phone; saveTx.timestamp = QDateTime::currentDateTimeUtc(); diff --git a/android/dushanbe/GetProfileInfoScript.cpp b/android/dushanbe/GetProfileInfoScript.cpp index b0cb5d6..a7e01cb 100644 --- a/android/dushanbe/GetProfileInfoScript.cpp +++ b/android/dushanbe/GetProfileInfoScript.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "adb/AdbUtils.h" @@ -96,7 +97,8 @@ void GetProfileInfoScript::doStart() { // Парсим ФИО и телефон из бокового меню const QString fullName = xmlScreenParser.parseProfileFullName(); - const QString phone = xmlScreenParser.parseProfilePhone(); + QString phone = xmlScreenParser.parseProfilePhone(); + phone.remove(QRegularExpression("[^0-9+]")); qDebug() << "[Dushanbe::GetProfileInfo] fullName:" << fullName << "phone:" << phone; if (!fullName.isEmpty()) { diff --git a/android/dushanbe/PayByCardScript.cpp b/android/dushanbe/PayByCardScript.cpp new file mode 100644 index 0000000..d412658 --- /dev/null +++ b/android/dushanbe/PayByCardScript.cpp @@ -0,0 +1,488 @@ +#include "PayByCardScript.h" + +#include +#include + +#include +#include + +#include "adb/AdbUtils.h" +#include "dao/BankProfileDAO.h" +#include "dao/DeviceDAO.h" +#include "dao/TransactionDAO.h" +#include "AppLogger.h" + +// Точечный OCR: находит строку "операции" и кропает правую часть для digit-only OCR +static QString recognizeOperationId(const QByteArray &pngData) { + Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), + static_cast(pngData.size())); + if (!pix) return {}; + + const int w = pixGetWidth(pix); + const int h = pixGetHeight(pix); + + auto *api = new tesseract::TessBaseAPI(); + if (api->Init(nullptr, "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(nullptr, "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 {}; +} + +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; + } + + 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.id; + qWarning() << m_error; + AppLogger::log("dushanbe/PayByCard", device.id, m_error, + AdbUtils::captureScreenshotBytes(device.adbSerial), + "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, 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 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)); + 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)); + 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 содержит "Сумма") + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма")); + 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(m_amount)) + ? QString::number(static_cast(m_amount)) + : QString::number(m_amount, 'f', 2); + AdbUtils::inputText(deviceId, amountStr); + QThread::msleep(1000); + + // 7. Скрываем клавиатуру и нажимаем "Далее" + AdbUtils::goBack(deviceId); + QThread::msleep(500); + 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. После "Далее" кнопка меняется на "Оплатить" — ждём и нажимаем + bool oplatitFound = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + 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 = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + // Пин-код может появиться + 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("Подтверждаю"))) { + // Тапаем внизу этого элемента (кнопка "Подтверждаю" в нижней части) + // Кнопка "Подтверждаю" всегда внизу bottom sheet — тапаем по 92% высоты экрана + const int tapY = height * 92 / 100; + qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at y=" << tapY; + AdbUtils::makeTap(deviceId, confirmNode.x(), tapY); + 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. Ждём результат — "Платеж выполнен" или ошибку + bool success = false; + for (int attempt = 0; attempt < 15; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + // Пин-код + 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; + } + + 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(); + + // Скриншот чека + const QByteArray receiptImage = AdbUtils::captureScreenshotBytes(deviceId); + + // Парсим детали из content-desc "Платеж выполнен" + // Формат: "Платеж выполнен\nПоставщик:\n...\nКомиссия:\n0.00\nДата:\n05.04.26\nВремя:\n20:19:56\nНа главную" + 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(); + } + } + qDebug() << "[Dushanbe::PayByCard] Result: date=" << bankTime << "fee=" << fee; + } + + // Переходим в историю для получения ID операции через OCR + // 1. Тапаем "На главную" (внизу экрана результата) + Node naGlavnuyuNode = xmlScreenParser.findNodeByContentDesc( + QString::fromUtf8("Платеж выполнен"), true); + if (!naGlavnuyuNode.isEmpty()) { + const int tapY = height * 92 / 100; + AdbUtils::makeTap(deviceId, width / 2, tapY); + QThread::msleep(2000); + } + + // 2. Тапаем "История" в нижней навигации + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node historyTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История")); + if (!historyTab.isEmpty()) { + AdbUtils::makeTap(deviceId, historyTab.x(), historyTab.y()); + QThread::msleep(3000); + } + + // 3. Тапаем первую транзакцию в списке (содержит m_cardNumber) + QString operationId; + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node txNode = xmlScreenParser.findNodeByContentDesc(m_cardNumber, true); + if (txNode.isEmpty()) { + // Fallback: ищем "Сегодня" — транзакция обычно сразу после + txNode = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Сегодня"), true); + } + if (!txNode.isEmpty()) { + AdbUtils::makeTap(deviceId, txNode.x(), txNode.y()); + QThread::msleep(3000); + + // 4. Делаем скриншот детали и OCR + const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(deviceId); + if (!detailScreenshot.isEmpty()) { + operationId = recognizeOperationId(detailScreenshot); + if (!operationId.isEmpty()) { + qDebug() << "[Dushanbe::PayByCard] Operation ID:" << operationId; + } else { + qWarning() << "[Dushanbe::PayByCard] Could not extract operation ID"; + } + } + + // Назад из деталей + AdbUtils::goBack(deviceId); + QThread::msleep(1000); + } else { + qWarning() << "[Dushanbe::PayByCard] Transaction not found in history"; + } + + // Сохраняем транзакцию + 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 \ No newline at end of file diff --git a/android/dushanbe/PayByCardScript.h b/android/dushanbe/PayByCardScript.h new file mode 100644 index 0000000..363eac1 --- /dev/null +++ b/android/dushanbe/PayByCardScript.h @@ -0,0 +1,36 @@ +#pragma once +#include "CommonScript.h" + +namespace Dushanbe { + +class PayByCardScript final : public CommonScript { + Q_OBJECT + +public: + PayByCardScript( + MaterialInfo account, + QString cardNumber, + double amount, + QObject *parent = nullptr + ); + + ~PayByCardScript() override; + +protected: + void doStart() override; + +private: + const MaterialInfo 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 Dushanbe \ No newline at end of file diff --git a/android/dushanbe/PayByPhoneScript.cpp b/android/dushanbe/PayByPhoneScript.cpp new file mode 100644 index 0000000..1927c5b --- /dev/null +++ b/android/dushanbe/PayByPhoneScript.cpp @@ -0,0 +1,473 @@ +#include "PayByPhoneScript.h" + +#include +#include + +#include +#include + +#include "adb/AdbUtils.h" +#include "dao/BankProfileDAO.h" +#include "dao/DeviceDAO.h" +#include "dao/TransactionDAO.h" +#include "AppLogger.h" + +static QString recognizeOperationId(const QByteArray &pngData) { + Pix *pix = pixReadMem(reinterpret_cast(pngData.constData()), + static_cast(pngData.size())); + if (!pix) return {}; + + const int w = pixGetWidth(pix); + const int h = pixGetHeight(pix); + + auto *api = new tesseract::TessBaseAPI(); + if (api->Init(nullptr, "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; + 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(nullptr, "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 {}; +} + +namespace Dushanbe { + +PayByPhoneScript::PayByPhoneScript( + MaterialInfo account, + QString phone, + double amount, + QObject *parent +) : CommonScript(parent), + m_account(std::move(account)), + m_phone(std::move(phone)), + m_amount(amount) { +} + +PayByPhoneScript::~PayByPhoneScript() = default; + +void PayByPhoneScript::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; + } + + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(device.adbSerial, ++m_counter)); + + if (!xmlScreenParser.isHomeScreen()) { + qDebug() << "[Dushanbe::PayByPhone] 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.id; + qWarning() << m_error; + AppLogger::log("dushanbe/PayByPhone", device.id, m_error, + AdbUtils::captureScreenshotBytes(device.adbSerial), + "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, device.screenWidth, device.screenHeight); + emit finishedWithResult(m_error); +} + +void PayByPhoneScript::makePayment( + const QString &deviceId, + const QString &pinCode, + 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::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + qDebug() << "[Dushanbe::PayByPhone] Tapping 'Переводы' tab"; + AdbUtils::makeTap(deviceId, transfersTab.x(), transfersTab.y()); + QThread::msleep(2000); + + // 2. Ждём экран переводов — ищем "DC (по номеру телефона)" + bool transfersLoaded = false; + Node phoneTransferBtn; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + phoneTransferBtn = xmlScreenParser.findNodeByContentDesc( + QString::fromUtf8("DC (по номеру телефона)"), true); + if (!phoneTransferBtn.isEmpty()) { + transfersLoaded = true; + break; + } + qDebug() << "[Dushanbe::PayByPhone] Waiting for transfers screen, attempt" << attempt + 1; + QThread::msleep(1500); + } + + if (!transfersLoaded) { + m_error = "Transfers screen not loaded or 'DC (по номеру телефона)' not found"; + qWarning() << "[Dushanbe::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + // 3. Тапаем "DC (по номеру телефона)" + qDebug() << "[Dushanbe::PayByPhone] Tapping 'DC (по номеру телефона)'"; + AdbUtils::makeTap(deviceId, phoneTransferBtn.x(), phoneTransferBtn.y()); + QThread::msleep(2000); + + // 4. Ждём экран ввода — ищем EditText с hint "Номер телефона получателя" + bool inputScreenLoaded = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node editText = xmlScreenParser.findEditTextByHint( + QString::fromUtf8("Номер телефона получателя")); + if (editText.isEmpty()) { + editText = xmlScreenParser.findFirstEditText(); + } + if (!editText.isEmpty()) { + inputScreenLoaded = true; + break; + } + qDebug() << "[Dushanbe::PayByPhone] Waiting for phone input screen, attempt" << attempt + 1; + QThread::msleep(1500); + } + + if (!inputScreenLoaded) { + m_error = "Phone input screen not loaded"; + qWarning() << "[Dushanbe::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + // 5. Вводим номер телефона + Node phoneEditText = xmlScreenParser.findEditTextByHint( + QString::fromUtf8("Номер телефона получателя")); + if (phoneEditText.isEmpty()) { + phoneEditText = xmlScreenParser.findFirstEditText(); + } + AdbUtils::makeTap(deviceId, phoneEditText.x(), phoneEditText.y()); + QThread::msleep(500); + + // Убираем "+" из номера если есть — вводим только цифры + QString phoneDigits = m_phone; + phoneDigits.remove('+'); + AdbUtils::inputText(deviceId, phoneDigits); + QThread::msleep(1000); + + // Скрываем клавиатуру чтобы поле суммы стало видимым + AdbUtils::goBack(deviceId); + QThread::msleep(1000); + + // 6. Вводим сумму в поле "Сумма" + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма")); + if (sumEditText.isEmpty()) { + m_error = "Sum input field not found"; + qWarning() << "[Dushanbe::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + AdbUtils::makeTap(deviceId, sumEditText.x(), sumEditText.y()); + QThread::msleep(500); + + const QString amountStr = (m_amount == static_cast(m_amount)) + ? QString::number(static_cast(m_amount)) + : QString::number(m_amount, 'f', 2); + AdbUtils::inputText(deviceId, amountStr); + QThread::msleep(1000); + + // 7. Скрываем клавиатуру и нажимаем "Далее" + AdbUtils::goBack(deviceId); + QThread::msleep(500); + 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::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + qDebug() << "[Dushanbe::PayByPhone] Tapping 'Далее'"; + AdbUtils::makeTap(deviceId, daleeBtn.x(), daleeBtn.y()); + QThread::msleep(2000); + + // 8. После "Далее" кнопка меняется на "Оплатить" + bool oplatitFound = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node oplatitBtn = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Оплатить")); + if (oplatitBtn.isEmpty()) { + oplatitBtn = xmlScreenParser.findButtonNode(QString::fromUtf8("Оплатить"), false); + } + if (!oplatitBtn.isEmpty()) { + qDebug() << "[Dushanbe::PayByPhone] Tapping 'Оплатить'"; + AdbUtils::makeTap(deviceId, oplatitBtn.x(), oplatitBtn.y()); + oplatitFound = true; + QThread::msleep(3000); + break; + } + qDebug() << "[Dushanbe::PayByPhone] Waiting for 'Оплатить', attempt" << attempt + 1; + QThread::msleep(1500); + } + + if (!oplatitFound) { + m_error = "Button 'Оплатить' not found"; + qWarning() << "[Dushanbe::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + // 9. Ждём bottom sheet "Подтверждение платежа" и тапаем "Подтверждаю" + bool confirmed = false; + for (int attempt = 0; attempt < 10; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + if (xmlScreenParser.isPinCodeScreen()) { + qDebug() << "[Dushanbe::PayByPhone] PIN code screen, entering PIN..."; + inputPinCode(deviceId, pinCode); + QThread::msleep(3000); + continue; + } + + Node confirmNode = xmlScreenParser.findNodeByContentDesc( + QString::fromUtf8("Подтверждение платежа"), true); + if (!confirmNode.isEmpty() && + confirmNode.contentDesc.contains(QString::fromUtf8("Подтверждаю"))) { + // Кнопка "Подтверждаю" всегда внизу bottom sheet — тапаем по 92% высоты экрана + const int tapY = height * 92 / 100; + qDebug() << "[Dushanbe::PayByPhone] Tapping 'Подтверждаю' at y=" << tapY; + AdbUtils::makeTap(deviceId, confirmNode.x(), tapY); + confirmed = true; + QThread::msleep(4000); + break; + } + + qDebug() << "[Dushanbe::PayByPhone] Waiting for confirmation sheet, attempt" << attempt + 1; + QThread::msleep(2000); + } + + if (!confirmed) { + m_error = "Confirmation sheet not found"; + qWarning() << "[Dushanbe::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + // 10. Ждём результат — "Платеж выполнен" или ошибку + bool success = false; + for (int attempt = 0; attempt < 15; ++attempt) { + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + + if (xmlScreenParser.isPinCodeScreen()) { + qDebug() << "[Dushanbe::PayByPhone] PIN code screen (retry), entering PIN..."; + inputPinCode(deviceId, pinCode); + QThread::msleep(3000); + continue; + } + + 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::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + qDebug() << "[Dushanbe::PayByPhone] Waiting for result, attempt" << attempt + 1; + QThread::msleep(2000); + } + + if (!success) { + m_error = "Payment result not received within timeout"; + qWarning() << "[Dushanbe::PayByPhone]" << m_error; + AppLogger::log("dushanbe/PayByPhone", deviceId, m_error, + AdbUtils::captureScreenshotBytes(deviceId), "CREATE_TRANSACTION"); + return; + } + + qDebug() << "[Dushanbe::PayByPhone] Payment successful!"; + m_error.clear(); + + // Скриншот чека + const QByteArray receiptImage = AdbUtils::captureScreenshotBytes(deviceId); + + // Парсим детали из content-desc "Платеж выполнен" + 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(); + } + } + qDebug() << "[Dushanbe::PayByPhone] Result: date=" << bankTime << "fee=" << fee; + } + + // Переходим в историю для получения ID операции через OCR + Node naGlavnuyuNode = xmlScreenParser.findNodeByContentDesc( + QString::fromUtf8("Платеж выполнен"), true); + if (!naGlavnuyuNode.isEmpty()) { + const int tapY = height * 92 / 100; + AdbUtils::makeTap(deviceId, width / 2, tapY); + QThread::msleep(2000); + } + + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node historyTab = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("История")); + if (!historyTab.isEmpty()) { + AdbUtils::makeTap(deviceId, historyTab.x(), historyTab.y()); + QThread::msleep(3000); + } + + QString operationId; + xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); + Node txNode = xmlScreenParser.findNodeByContentDesc(phoneDigits, true); + if (txNode.isEmpty()) { + txNode = xmlScreenParser.findNodeByContentDesc(QString::fromUtf8("Сегодня"), true); + } + if (!txNode.isEmpty()) { + AdbUtils::makeTap(deviceId, txNode.x(), txNode.y()); + QThread::msleep(3000); + + const QByteArray detailScreenshot = AdbUtils::captureScreenshotBytes(deviceId); + if (!detailScreenshot.isEmpty()) { + operationId = recognizeOperationId(detailScreenshot); + if (!operationId.isEmpty()) { + qDebug() << "[Dushanbe::PayByPhone] Operation ID:" << operationId; + } else { + qWarning() << "[Dushanbe::PayByPhone] Could not extract operation ID"; + } + } + + AdbUtils::goBack(deviceId); + QThread::msleep(1000); + } else { + qWarning() << "[Dushanbe::PayByPhone] Transaction not found in history"; + } + + // Сохраняем транзакцию + TransactionInfo tx; + tx.accountId = m_account.id; + tx.amount = -m_amount; + tx.fee = fee; + tx.status = TransactionStatus::Complete; + tx.type = TransactionType::Phone; + tx.phone = m_phone; + 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::PayByPhone] Transaction saved, id:" << txId + << "operationId:" << operationId; + if (!receiptImage.isEmpty()) { + TransactionDAO::updateReceiptImage(txId, receiptImage); + } + } else { + qWarning() << "[Dushanbe::PayByPhone] Failed to save transaction"; + } +} + +} // namespace Dushanbe diff --git a/android/dushanbe/PayByPhoneScript.h b/android/dushanbe/PayByPhoneScript.h new file mode 100644 index 0000000..32402bd --- /dev/null +++ b/android/dushanbe/PayByPhoneScript.h @@ -0,0 +1,36 @@ +#pragma once +#include "CommonScript.h" + +namespace Dushanbe { + +class PayByPhoneScript final : public CommonScript { + Q_OBJECT + +public: + PayByPhoneScript( + MaterialInfo account, + QString phone, + double amount, + QObject *parent = nullptr + ); + + ~PayByPhoneScript() override; + +protected: + void doStart() override; + +private: + const MaterialInfo m_account; + const QString m_phone; + const double m_amount; + QString m_error; + + void makePayment( + const QString &deviceId, + const QString &pinCode, + int width, + int height + ); +}; + +} // namespace Dushanbe diff --git a/android/dushanbe/ScreenXmlParser.cpp b/android/dushanbe/ScreenXmlParser.cpp index 90a191b..ea060d3 100644 --- a/android/dushanbe/ScreenXmlParser.cpp +++ b/android/dushanbe/ScreenXmlParser.cpp @@ -467,7 +467,7 @@ ScreenXmlParser::TransactionDetail ScreenXmlParser::parseTransactionDetail() { Node ScreenXmlParser::findEditTextByHint(const QString &hint) { for (const Node &node : m_nodes) { - if (node.className == "android.widget.EditText" && node.hint == hint) { + if (node.className == "android.widget.EditText" && node.hint.contains(hint)) { return node; } } diff --git a/android/ozon/GetProfileInfoScript.cpp b/android/ozon/GetProfileInfoScript.cpp index 3c980e3..934a7fc 100644 --- a/android/ozon/GetProfileInfoScript.cpp +++ b/android/ozon/GetProfileInfoScript.cpp @@ -1,7 +1,6 @@ #include "GetProfileInfoScript.h" #include -#include #include #include #include diff --git a/assets/dushanbe/card_transfer_confirm.png b/assets/dushanbe/card_transfer_confirm.png new file mode 100644 index 0000000..855fcbe Binary files /dev/null and b/assets/dushanbe/card_transfer_confirm.png differ diff --git a/assets/dushanbe/card_transfer_confirm.xml b/assets/dushanbe/card_transfer_confirm.xml new file mode 100644 index 0000000..172de9b --- /dev/null +++ b/assets/dushanbe/card_transfer_confirm.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/card_transfer_confirm_detail.png b/assets/dushanbe/card_transfer_confirm_detail.png new file mode 100644 index 0000000..fb7f313 Binary files /dev/null and b/assets/dushanbe/card_transfer_confirm_detail.png differ diff --git a/assets/dushanbe/card_transfer_confirm_detail.xml b/assets/dushanbe/card_transfer_confirm_detail.xml new file mode 100644 index 0000000..17ff53c --- /dev/null +++ b/assets/dushanbe/card_transfer_confirm_detail.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/card_transfer_input.png b/assets/dushanbe/card_transfer_input.png new file mode 100644 index 0000000..88fa6c9 Binary files /dev/null and b/assets/dushanbe/card_transfer_input.png differ diff --git a/assets/dushanbe/card_transfer_input.xml b/assets/dushanbe/card_transfer_input.xml new file mode 100644 index 0000000..3f04567 --- /dev/null +++ b/assets/dushanbe/card_transfer_input.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/card_transfer_success.png b/assets/dushanbe/card_transfer_success.png new file mode 100644 index 0000000..03b7385 Binary files /dev/null and b/assets/dushanbe/card_transfer_success.png differ diff --git a/assets/dushanbe/card_transfer_success.xml b/assets/dushanbe/card_transfer_success.xml new file mode 100644 index 0000000..7c9e25a --- /dev/null +++ b/assets/dushanbe/card_transfer_success.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/card_transfer_tx_detail.png b/assets/dushanbe/card_transfer_tx_detail.png new file mode 100644 index 0000000..394b2be Binary files /dev/null and b/assets/dushanbe/card_transfer_tx_detail.png differ diff --git a/assets/dushanbe/card_transfer_tx_detail.xml b/assets/dushanbe/card_transfer_tx_detail.xml new file mode 100644 index 0000000..9d15d7b --- /dev/null +++ b/assets/dushanbe/card_transfer_tx_detail.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/phone_transfer_confirm.png b/assets/dushanbe/phone_transfer_confirm.png new file mode 100644 index 0000000..dd97ac9 Binary files /dev/null and b/assets/dushanbe/phone_transfer_confirm.png differ diff --git a/assets/dushanbe/phone_transfer_confirm.xml b/assets/dushanbe/phone_transfer_confirm.xml new file mode 100644 index 0000000..2e492f8 --- /dev/null +++ b/assets/dushanbe/phone_transfer_confirm.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/phone_transfer_input.png b/assets/dushanbe/phone_transfer_input.png new file mode 100644 index 0000000..b3248a1 Binary files /dev/null and b/assets/dushanbe/phone_transfer_input.png differ diff --git a/assets/dushanbe/phone_transfer_input.xml b/assets/dushanbe/phone_transfer_input.xml new file mode 100644 index 0000000..84e92c1 --- /dev/null +++ b/assets/dushanbe/phone_transfer_input.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/phone_transfer_success.png b/assets/dushanbe/phone_transfer_success.png new file mode 100644 index 0000000..7013b24 Binary files /dev/null and b/assets/dushanbe/phone_transfer_success.png differ diff --git a/assets/dushanbe/phone_transfer_success.xml b/assets/dushanbe/phone_transfer_success.xml new file mode 100644 index 0000000..baa36a2 --- /dev/null +++ b/assets/dushanbe/phone_transfer_success.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/assets/dushanbe/transfers_screen.png b/assets/dushanbe/transfers_screen.png new file mode 100644 index 0000000..9cc040d Binary files /dev/null and b/assets/dushanbe/transfers_screen.png differ diff --git a/assets/dushanbe/transfers_screen.xml b/assets/dushanbe/transfers_screen.xml new file mode 100644 index 0000000..30ddbf1 --- /dev/null +++ b/assets/dushanbe/transfers_screen.xml @@ -0,0 +1 @@ +UI hierchary dumped to: /dev/tty diff --git a/config.ini b/config.ini index c4204d3..3e01cd8 100644 --- a/config.ini +++ b/config.ini @@ -1,5 +1,5 @@ [apps] -list=ozon, black, dushanbe +list=ozon, black, dushanbe_city_bank [common] currencies=USD, ARS, RUB, TJS @@ -30,9 +30,8 @@ currency=RUB name=Ozon phone_code=7 -[dushanbe] +[dushanbe_city_bank] android_package_name=tj.dc.next1 currency=TJS name=Dushanbe City -phone_code=992 - +phone_code=992 \ No newline at end of file diff --git a/database/dao/EventDAO.cpp b/database/dao/EventDAO.cpp index 040752b..9265d9a 100644 --- a/database/dao/EventDAO.cpp +++ b/database/dao/EventDAO.cpp @@ -2,6 +2,7 @@ #include #include +#include #include "DatabaseManager.h" #include "time/DateUtils.h" @@ -23,6 +24,10 @@ EventInfo parseEvent(const QSqlQuery &query) { event.timestamp = DateUtils::fromUtcString(query.value("timestamp").toString()); event.updateTime = DateUtils::fromUtcString(query.value("update_time").toString()); event.completeTime = DateUtils::fromUtcString(query.value("complete_time").toString()); + const int hasScreenshotIdx = query.record().indexOf("has_screenshot"); + if (hasScreenshotIdx >= 0) { + event.hasScreenshot = query.value(hasScreenshotIdx).toBool(); + } return event; } @@ -189,7 +194,8 @@ QList EventDAO::getEvents(int page, int pageSize) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( SELECT id, account_id, device_id, external_id, amount, phone, bank_name, - status, type, comment, json, sent_to_server, update_time, complete_time, timestamp + status, type, comment, json, sent_to_server, update_time, complete_time, timestamp, + (LENGTH(screenshot) > 0) as has_screenshot FROM events ORDER BY id DESC LIMIT :limit OFFSET :offset @@ -340,3 +346,24 @@ QByteArray EventDAO::getScreenshot(int id) { if (!query.exec() || !query.next()) return {}; return query.value(0).toByteArray(); } + +QList EventDAO::getRecentEvents(int hours) { + QList events; + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + SELECT id, account_id, device_id, external_id, amount, phone, bank_name, + status, type, comment, json, sent_to_server, update_time, complete_time, timestamp + FROM events + WHERE timestamp >= datetime('now', :hours) + ORDER BY id DESC + )"); + query.bindValue(":hours", QString("-%1 hours").arg(hours)); + if (!query.exec()) { + qWarning() << "Ошибка при getRecentEvents:" << query.lastError().text(); + return events; + } + while (query.next()) { + events.append(parseEvent(query)); + } + return events; +} diff --git a/database/dao/EventDAO.h b/database/dao/EventDAO.h index 4520713..ed2228e 100644 --- a/database/dao/EventDAO.h +++ b/database/dao/EventDAO.h @@ -38,4 +38,6 @@ public: [[nodiscard]] static bool hasPendingEvents(); [[nodiscard]] static QSet getDevicesWithPendingEvents(); + + [[nodiscard]] static QList getRecentEvents(int hours); }; diff --git a/images/dushanbe_icon.png b/images/dushanbe_city_bank_icon.png similarity index 100% rename from images/dushanbe_icon.png rename to images/dushanbe_city_bank_icon.png diff --git a/models/db/EventInfo.h b/models/db/EventInfo.h index de8fe5e..68fae83 100644 --- a/models/db/EventInfo.h +++ b/models/db/EventInfo.h @@ -71,6 +71,7 @@ struct EventInfo { bool sentToServer = false; QByteArray screenshot; + bool hasScreenshot = false; QDateTime updateTime; QDateTime completeTime; diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index b8b663f..5412c9c 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -29,6 +29,8 @@ #include "ozon/PayByPhoneScript.h" #include "dushanbe/LoginAndCheckAccountsScript.h" #include "dushanbe/GetLastTransactionsScript.h" +#include "dushanbe/PayByCardScript.h" +#include "dushanbe/PayByPhoneScript.h" static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data); static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description); @@ -125,6 +127,9 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { event.json = QJsonDocument(task).toJson(QJsonDocument::Compact); const QJsonObject body = task["body"].toObject(); + if (eventType == EventType::FetchTransactions) { + event.amount = body["amount"].toDouble() / 100.0; + } if (eventType == EventType::CreateTransaction) { event.amount = body["amount"].toDouble() / 100.0; event.phone = body["bank_participator_id"].toString(); @@ -169,12 +174,18 @@ static void sendTaskResult(const QString &type, const QString &materialId, const auto *networkService = new NetworkService; networkService->moveToThread(thread); networkService->setPayload(QJsonDocument(obj).toJson()); - QObject::connect(thread, &QThread::started, networkService, [networkService, eventId]() { - networkService->postTaskResult(); - if (eventId != -1) { - EventDAO::markSentToServer(eventId); - } - }); + QObject::connect(thread, &QThread::started, networkService, &NetworkService::postTaskResult); + QObject::connect(networkService, &NetworkService::finishedWithResult, networkService, + [eventId](int result) { + if (eventId != -1) { + if (result == 0) { + EventDAO::markSentToServer(eventId); + } else { + EventDAO::updateEvent(eventId, EventStatus::Error, "Failed to send result to server"); + qWarning() << "[EventHandler] Task result not sent, marking event" << eventId << "as error"; + } + } + }); QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit); QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); @@ -306,18 +317,26 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, 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()) { + const QTimeZone bankTz(event.bankName == "dushanbe_city_bank" + ? "Asia/Dushanbe" : "Europe/Moscow"); + // Приоритет: bankTime (время операции) > timestamp (время сохранения) + // Сервер ожидает UTC ("Z") + bool createdAtSet = false; + if (!tx.bankTime.isEmpty()) { QDateTime dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy, HH:mm"); if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm"); + if (!dt.isValid()) dt = QDateTime::fromString(tx.bankTime, "dd.MM.yyyy HH:mm:ss"); if (dt.isValid()) { - dt.setTimeZone(QTimeZone("Europe/Moscow")); - txObj["created_at"] = dt.toString(Qt::ISODateWithMs); - } else { - txObj["created_at"] = QJsonValue(QJsonValue::Null); + dt.setTimeZone(bankTz); + txObj["created_at"] = dt.toUTC().toString(Qt::ISODate); + createdAtSet = true; } - } else { + } + if (!createdAtSet && tx.timestamp.isValid()) { + txObj["created_at"] = tx.timestamp.toUTC().toString(Qt::ISODate); + createdAtSet = true; + } + if (!createdAtSet) { txObj["created_at"] = QJsonValue(QJsonValue::Null); } arr.append(txObj); @@ -334,7 +353,7 @@ void EventHandler::updateEventThread(const QString &key, const EventInfo &event, 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); + obj["created_at"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate); // Берём bank_transaction_id и fee из последней сохранённой транзакции QList txList = TransactionDAO::getTransactionsWithinHours(event.accountId, 1); @@ -420,7 +439,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn if (event.type == EventType::FetchProfile) { if (appCode == "ozon") setup(new Ozon::LoginAndCheckAccountsScript(adbSerial, appCode, nullptr, true)); else if (appCode == "black") setup(new Black::LoginAndCheckAccountsScript(adbSerial, appCode)); - else if (appCode == "dushanbe") setup(new Dushanbe::LoginAndCheckAccountsScript(adbSerial, appCode)); + else if (appCode == "dushanbe_city_bank") setup(new Dushanbe::LoginAndCheckAccountsScript(adbSerial, appCode)); else qWarning() << "[EventHandler] FETCH_PROFILE: unknown appCode:" << appCode; } @@ -435,7 +454,7 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn setup(new Ozon::GetLastTransactionsScript(adbSerial, appCode, 0, searchAmount, searchTime)); else if (appCode == "black") setup(new Black::GetLastTransactionsScript(adbSerial, appCode)); - else if (appCode == "dushanbe") + else if (appCode == "dushanbe_city_bank") setup(new Dushanbe::GetLastTransactionsScript(adbSerial, appCode, searchAmount, searchTime)); else qWarning() << "[EventHandler] FETCH_TRANSACTIONS: unknown appCode:" << appCode; } @@ -451,10 +470,12 @@ void EventHandler::startThreadForTask(const MaterialInfo &account, const EventIn // Перевод по номеру карты if (appCode == "ozon") setup(new Ozon::PayByCardScript(account, event.phone, event.amount)); else if (appCode == "black") setup(new Black::PayByCardScript(account, event.phone, event.amount)); + else if (appCode == "dushanbe_city_bank") setup(new Dushanbe::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 == "dushanbe_city_bank") setup(new Dushanbe::PayByPhoneScript(account, event.phone, event.amount)); else qWarning() << "[EventHandler] CREATE_TRANSACTION: unknown appCode:" << appCode; } } diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index b728ccb..f8ce951 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -1228,6 +1228,9 @@ void NetworkService::postTaskResult() { const QJsonValue result = postJson(m_apiBase + m_pathTaskResult, body, true); if (result.isUndefined()) { qWarning() << "[NetworkService] postTaskResult failed, body:" << QJsonDocument(body).toJson(QJsonDocument::Compact); + emit finishedWithResult(-1); + } else { + emit finishedWithResult(0); } emit finished(); } @@ -1251,8 +1254,13 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event multiPart->append(imagePart); } + ensureValidToken(); + const QUrl url(m_apiBase + m_pathMonitoringLog); QNetworkRequest request(url); + if (!m_accessToken.isEmpty()) { + request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8()); + } QNetworkReply *reply = m_manager->post(request, multiPart); multiPart->setParent(reply); diff --git a/tutorials/image_1.jpg b/tutorials/image_1.jpg new file mode 100644 index 0000000..e06b248 Binary files /dev/null and b/tutorials/image_1.jpg differ diff --git a/tutorials/image_2.jpg b/tutorials/image_2.jpg new file mode 100644 index 0000000..3973818 Binary files /dev/null and b/tutorials/image_2.jpg differ diff --git a/tutorials/test.md b/tutorials/test.md deleted file mode 100644 index abe8d15..0000000 --- a/tutorials/test.md +++ /dev/null @@ -1,17 +0,0 @@ -# Руководство: Запуск приложения TutorialWindow - -Этот файл объясняет, как собрать и запустить ваше Qt6-приложение с окном TutorialWindow. - ---- - -## 1. Требования - -- Qt 6 (рекомендуется версия 6.2 или выше) -- C++17-совместимый компилятор (GCC, Clang, MSVC) -- CMake (версия ≥ 3.16) или qmake - ---- - -## 2. Структура проекта -[Текст ссылки](https://example.com) - diff --git a/tutorials/test2.md b/tutorials/test2.md deleted file mode 100644 index 21604fb..0000000 --- a/tutorials/test2.md +++ /dev/null @@ -1,16 +0,0 @@ -# Руководство: Запуск приложения TutorialWindow - -Этот файл объясняет, как собрать и запустить ваше Qt6-приложение с окном TutorialWindow. - ---- - -## 1. Требования - -- Qt 6 (рекомендуется версия 6.2 или выше) -- C++17-совместимый компилятор (GCC, Clang, MSVC) -- CMake (версия ≥ 3.16) или qmake - ---- - -## 2. Структура проекта - diff --git a/tutorials/test3.md b/tutorials/test3.md deleted file mode 100644 index 21604fb..0000000 --- a/tutorials/test3.md +++ /dev/null @@ -1,16 +0,0 @@ -# Руководство: Запуск приложения TutorialWindow - -Этот файл объясняет, как собрать и запустить ваше Qt6-приложение с окном TutorialWindow. - ---- - -## 1. Требования - -- Qt 6 (рекомендуется версия 6.2 или выше) -- C++17-совместимый компилятор (GCC, Clang, MSVC) -- CMake (версия ≥ 3.16) или qmake - ---- - -## 2. Структура проекта - diff --git a/tutorials/ui_info.md b/tutorials/ui_info.md new file mode 100644 index 0000000..3d4125e --- /dev/null +++ b/tutorials/ui_info.md @@ -0,0 +1,38 @@ +# Описание интерфейса + +--- + +## Главный экран — Устройство + + + + + + +
+

1 — Название устройства и ID

+

2 — Кнопка настроек устройств (там вы увидите какие банки поддерживаются приложением)

+

3 — Кнопка удаления устройства (удалит все данные устройства и если подключите снова, создаст новое)

+

4 — Статус банковского профиля, красный - выключен, зеленый - включен

+

5 — Настройки банковского профиля. Если нет то самый первый раз будет мастер настроеек профиля.

+

6 — Имя профиля и номер телефона банковского профиля (если настроен)

+

7 — Кнопка включения/выключения банковского профиля

+
+ +--- + +## Настройки банка + + + + + + +
+

1 — Включить / выключить профиль банка

+

2 — Редактировать пин-код

+

3 — Проверить пин-код на устройстве

+

4 — Включить / выключить счёт

+

5 — Обновить профиль (ФИО, телефон, баланс)

+

6 — Проверить аккаунты (карты и счета)

+
diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index e07e036..a273f25 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -8,11 +8,16 @@ #include #include +#include +#include + #include "TutorialWindow.h" #include "bank/BankListWindow.h" #include "event/EventWindow.h" #include "log/LogWindow.h" #include "log/FileLogWindow.h" +#include "dao/GeneralLogDAO.h" +#include "net/NetworkService.h" #include "db/DeviceInfo.h" #include "device/DeviceWidget.h" #include "dao/DeviceDAO.h" @@ -96,13 +101,19 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_eventWindow->activateWindow(); }); - connect(fileLogAction, &QAction::triggered, this, [=]() { - if (!m_fileLogWindow) { - m_fileLogWindow = new FileLogWindow(this); + connect(fileLogAction, &QAction::triggered, this, [this]() { + QMessageBox msgBox(this); + msgBox.setWindowTitle("Логи"); + msgBox.setText("Отправить логи на сервер?"); + auto *yesBtn = msgBox.addButton("Отправить", QMessageBox::YesRole); + msgBox.addButton("Отмена", QMessageBox::NoRole); + msgBox.exec(); + if (msgBox.clickedButton() == yesBtn) { + if (!m_fileLogWindow) { + m_fileLogWindow = new FileLogWindow(this); + } + m_fileLogWindow->sendLogToTelegram(); } - m_fileLogWindow->show(); - m_fileLogWindow->raise(); - m_fileLogWindow->activateWindow(); }); if (SettingsDAO::get("tutorial_shown").isEmpty()) { diff --git a/views/MainWindow.h b/views/MainWindow.h index 84e66a7..e3d03b0 100644 --- a/views/MainWindow.h +++ b/views/MainWindow.h @@ -9,6 +9,7 @@ #include "TutorialWindow.h" #include "bank/AccountWindow.h" #include "db/DeviceInfo.h" +#include "log/FileLogWindow.h" #include "widget/common/FlowLayout.h" class MainWindow final : public QMainWindow { @@ -32,7 +33,7 @@ private: QWidget *m_accountsWindow = nullptr; QWidget *m_tutorialWindow = nullptr; QWidget *m_bankListWindow = nullptr; - QWidget *m_fileLogWindow = nullptr; + FileLogWindow *m_fileLogWindow = nullptr; QWidget *m_eventWindow = nullptr; void createDevicePage(); diff --git a/views/bank/BankSettingsWindow.cpp b/views/bank/BankSettingsWindow.cpp index 54073c8..01cd2b3 100644 --- a/views/bank/BankSettingsWindow.cpp +++ b/views/bank/BankSettingsWindow.cpp @@ -76,7 +76,7 @@ void BankSettingsWindow::startCheckPinCode(const QString &appCode) { connect(worker, &Black::LoginAndCheckAccountsScript::finished, thread, &QThread::quit); connect(worker, &Black::LoginAndCheckAccountsScript::finished, worker, &QObject::deleteLater); connect(thread, &QThread::finished, thread, &QObject::deleteLater); - } else if (appCode == "dushanbe") { + } else if (appCode == "dushanbe_city_bank") { auto *worker = new Dushanbe::LoginAndCheckAccountsScript(m_deviceId, appCode, nullptr); worker->moveToThread(thread); connect(thread, &QThread::started, worker, &Dushanbe::LoginAndCheckAccountsScript::start); diff --git a/views/bank/BankSetupWizard.cpp b/views/bank/BankSetupWizard.cpp index 2c64000..6d0b331 100644 --- a/views/bank/BankSetupWizard.cpp +++ b/views/bank/BankSetupWizard.cpp @@ -398,7 +398,7 @@ void BankSetupWizard::runPinCheck() { }, Qt::QueuedConnection); connect(worker, &Black::LoginAndCheckAccountsScript::finished, worker, &QObject::deleteLater); connect(worker, &Black::LoginAndCheckAccountsScript::finished, m_workerThread, &QThread::quit); - } else if (appCode == "dushanbe") { + } else if (appCode == "dushanbe_city_bank") { auto *worker = new Dushanbe::LoginAndCheckAccountsScript(deviceId, appCode, nullptr, true); worker->moveToThread(m_workerThread); connect(m_workerThread, &QThread::started, worker, &Dushanbe::LoginAndCheckAccountsScript::start); @@ -459,7 +459,7 @@ void BankSetupWizard::runProfileCheck() { connect(worker, &Black::GetProfileInfoScript::finishedWithResult, this, onProfileResult, Qt::QueuedConnection); connect(worker, &Black::GetProfileInfoScript::finished, worker, &QObject::deleteLater); connect(worker, &Black::GetProfileInfoScript::finished, m_workerThread, &QThread::quit); - } else if (appCode == "dushanbe") { + } else if (appCode == "dushanbe_city_bank") { auto *worker = new Dushanbe::GetProfileInfoScript(deviceId, appCode, nullptr); worker->moveToThread(m_workerThread); connect(m_workerThread, &QThread::started, worker, &Dushanbe::GetProfileInfoScript::start); @@ -514,7 +514,7 @@ void BankSetupWizard::runCardCheck() { connect(worker, &Black::GetCardInfoScript::finishedWithResult, this, onCardResult, Qt::QueuedConnection); connect(worker, &Black::GetCardInfoScript::finished, worker, &QObject::deleteLater); connect(worker, &Black::GetCardInfoScript::finished, m_workerThread, &QThread::quit); - } else if (appCode == "dushanbe") { + } else if (appCode == "dushanbe_city_bank") { auto *worker = new Dushanbe::GetCardInfoScript(deviceId, appCode, nullptr); worker->moveToThread(m_workerThread); connect(m_workerThread, &QThread::started, worker, &Dushanbe::GetCardInfoScript::start); diff --git a/views/device/DeviceSettingsWindow.cpp b/views/device/DeviceSettingsWindow.cpp index f05bfde..6c94396 100644 --- a/views/device/DeviceSettingsWindow.cpp +++ b/views/device/DeviceSettingsWindow.cpp @@ -147,7 +147,7 @@ void DeviceSettingsWindow::reloadContent(QTableWidget *tableWidget) { connect(settingsBtn, &QPushButton::clicked, this, [this, app, tableWidget]() { const BankProfileInfo dbApp = BankProfileDAO::getApplication(m_deviceId, app.code); - if (dbApp.id < 0 || dbApp.bankProfileId.isEmpty()) { + if (dbApp.id < 0 || !dbApp.pinCodeChecked) { auto *wizard = new BankSetupWizard(this, m_deviceId, dbApp.id >= 0 ? dbApp : app); wizard->setAttribute(Qt::WA_DeleteOnClose); connect(wizard, &QDialog::finished, this, [this, tableWidget]() { diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index 2ddc18c..dac83f7 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -239,7 +239,7 @@ void DeviceWidget::setOpenBanksSettings(QPushButton *button, const BankProfileIn QString deviceName = m_device.name; connect(button, &QPushButton::clicked, this, [deviceId, deviceName, app]() { const BankProfileInfo dbApp = BankProfileDAO::getApplication(deviceId, app.code); - if (dbApp.id < 0 || dbApp.bankProfileId.isEmpty()) { + if (dbApp.id < 0 || !dbApp.pinCodeChecked) { auto *wizard = new BankSetupWizard(nullptr, deviceId, dbApp.id >= 0 ? dbApp : app); wizard->setAttribute(Qt::WA_DeleteOnClose); wizard->show(); diff --git a/views/event/EventWindow.cpp b/views/event/EventWindow.cpp index 7f40a81..ffe7f16 100644 --- a/views/event/EventWindow.cpp +++ b/views/event/EventWindow.cpp @@ -1,15 +1,23 @@ #include "EventWindow.h" #include +#include +#include #include #include +#include #include #include +#include +#include +#include #include +#include #include #include #include "dao/EventDAO.h" +#include "dao/SettingsDAO.h" #include "net/NetworkService.h" static constexpr int COL_ID = 0; @@ -22,7 +30,8 @@ static constexpr int COL_AMOUNT = 6; static constexpr int COL_COMMENT = 7; static constexpr int COL_JSON = 8; static constexpr int COL_SENT = 9; -static constexpr int COL_ACTION = 10; +static constexpr int COL_IMG = 10; +static constexpr int COL_ACTION = 11; static void sendErrorToServer(const QString &type, const QString &materialId, const QString &bankName) { QJsonObject errData; @@ -71,15 +80,15 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { // ── Таблица ─────────────────────────────────────────────────────────────── m_table = new QTableWidget(this); - m_table->setColumnCount(11); + m_table->setColumnCount(12); m_table->setHorizontalHeaderLabels({ - "#", "Время", "Тип", "Банк", "Устройство", "Статус", "Сумма", "Комментарий", "JSON", "Отпр.", "" + "#", "Время", "Тип", "Банк", "Устройство", "Статус", "Сумма", "Комментарий", "JSON", "Отпр.", "Скрин", "" }); m_table->setEditTriggers(QAbstractItemView::NoEditTriggers); m_table->setSelectionBehavior(QAbstractItemView::SelectRows); m_table->setSelectionMode(QAbstractItemView::SingleSelection); m_table->verticalHeader()->hide(); - m_table->horizontalHeader()->setStretchLastSection(true); + m_table->horizontalHeader()->setStretchLastSection(false); m_table->setColumnWidth(COL_ID, 45); m_table->setColumnWidth(COL_TIME, 145); m_table->setColumnWidth(COL_TYPE, 100); @@ -87,9 +96,11 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { m_table->setColumnWidth(COL_DEVICE, 120); m_table->setColumnWidth(COL_STATUS, 100); m_table->setColumnWidth(COL_AMOUNT, 80); - m_table->setColumnWidth(COL_JSON, 200); + m_table->setColumnHidden(COL_JSON, true); + m_table->setColumnHidden(COL_ACTION, true); m_table->setColumnWidth(COL_SENT, 45); - m_table->setColumnWidth(COL_ACTION, 80); + m_table->setColumnWidth(COL_IMG, 45); + m_table->horizontalHeader()->setSectionResizeMode(COL_COMMENT, QHeaderView::Stretch); // ── Панель скриншота ────────────────────────────────────────────────────── m_screenshotLabel = new QLabel(this); @@ -112,6 +123,8 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { m_pageLabel->setMinimumWidth(140); auto *refreshBtn = new QPushButton("Обновить", this); + m_sendBtn = new QPushButton("Отправить", this); + m_sendBtn->setStyleSheet("background-color: #0088cc; color: white; border-radius: 4px; padding: 4px 12px;"); auto *paginationLayout = new QHBoxLayout; paginationLayout->addWidget(m_prevBtn); @@ -119,6 +132,7 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { paginationLayout->addWidget(m_nextBtn); paginationLayout->addStretch(); paginationLayout->addWidget(refreshBtn); + paginationLayout->addWidget(m_sendBtn); auto *mainLayout = new QVBoxLayout(this); mainLayout->addWidget(splitter, 1); @@ -140,6 +154,8 @@ EventWindow::EventWindow(QWidget *parent) : QDialog(parent) { showScreenshot(m_eventIds[row]); } }); + connect(m_sendBtn, &QPushButton::clicked, this, &EventWindow::sendEventsToCsv); + connect(m_table, &QTableWidget::cellDoubleClicked, this, [this](int row, int col) { if (col != COL_JSON) return; auto *item = m_table->item(row, col); @@ -190,6 +206,7 @@ void EventWindow::loadPage() { m_table->setItem(i, COL_JSON, jsonItem); m_table->setItem(i, COL_SENT, new QTableWidgetItem(e.sentToServer ? "+" : "")); + m_table->setItem(i, COL_IMG, new QTableWidgetItem(e.hasScreenshot ? "+" : "")); // Подсветка строки по статусу QColor bg; @@ -247,3 +264,138 @@ void EventWindow::updatePagination() { m_prevBtn->setEnabled(m_page > 0); m_nextBtn->setEnabled(m_page < m_totalPages - 1); } + +void EventWindow::sendEventsToCsv() { + const QList events = EventDAO::getRecentEvents(48); + if (events.isEmpty()) { + QMessageBox::information(this, "События", "Нет событий за последние 48 часов."); + return; + } + + m_sendBtn->setEnabled(false); + m_sendBtn->setText("Отправка..."); + + // Формируем CSV + QString csv; + csv += "id;time;type;bank;device;status;amount;phone;comment;sent\n"; + for (const auto &e : events) { + csv += QString("%1;%2;%3;%4;%5;%6;%7;%8;%9;%10\n") + .arg(e.id) + .arg(e.timestamp.toLocalTime().toString("dd.MM.yyyy HH:mm:ss")) + .arg(eventTypeToString(e.type)) + .arg(e.bankName) + .arg(e.deviceId) + .arg(eventStatusToString(e.status)) + .arg(e.amount, 0, 'f', 2) + .arg(e.phone) + .arg(QString(e.comment).replace(';', ',')) + .arg(e.sentToServer ? "yes" : "no"); + } + + // Собираем временную папку + const QString tmpDir = QDir::tempPath() + "/arc_events_export"; + QDir().mkpath(tmpDir); + QDir().mkpath(tmpDir + "/screenshots"); + + // Сохраняем CSV + { + QFile csvFile(tmpDir + "/events.csv"); + if (csvFile.open(QIODevice::WriteOnly | QIODevice::Text)) { + csvFile.write(csv.toUtf8()); + csvFile.close(); + } + } + + // Сохраняем скриншоты + int screenshotCount = 0; + for (const auto &e : events) { + const QByteArray screenshot = EventDAO::getScreenshot(e.id); + if (screenshot.isEmpty()) continue; + const QString fileName = QString("screenshots/%1_%2_%3.png") + .arg(e.id) + .arg(eventTypeToString(e.type)) + .arg(e.timestamp.toString("yyyyMMdd_HHmmss")); + QFile imgFile(tmpDir + "/" + fileName); + if (imgFile.open(QIODevice::WriteOnly)) { + imgFile.write(screenshot); + imgFile.close(); + ++screenshotCount; + } + } + + // Создаём ZIP + const QString zipPath = QDir::tempPath() + "/arc_events.zip"; + QFile::remove(zipPath); + + QProcess zip; + zip.setWorkingDirectory(tmpDir); + zip.start("zip", QStringList() << "-r" << zipPath << "."); + zip.waitForFinished(30000); + + QDir(tmpDir).removeRecursively(); + + if (zip.exitCode() != 0 || !QFile::exists(zipPath)) { + QMessageBox::warning(this, "Ошибка", "Не удалось создать архив"); + m_sendBtn->setEnabled(true); + m_sendBtn->setText("Отправить"); + return; + } + + // Отправляем в Telegram + const QString botToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8"; + const QString chatId = "-5177086220"; + const QString desktopId = SettingsDAO::get("desktop_id"); + const QString caption = "Events (48h) from desktop: " + desktopId + + "\nTotal: " + QString::number(events.size()) + + "\nScreenshots: " + QString::number(screenshotCount); + + auto *sendFile = new QFile(zipPath); + if (!sendFile->open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, "Ошибка", "Не удалось открыть архив"); + delete sendFile; + m_sendBtn->setEnabled(true); + m_sendBtn->setText("Отправить"); + return; + } + + const QUrl url("https://api.telegram.org/bot" + botToken + "/sendDocument"); + auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); + + QHttpPart chatPart; + chatPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="chat_id")"); + chatPart.setBody(chatId.toUtf8()); + multiPart->append(chatPart); + + QHttpPart captionPart; + captionPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="caption")"); + captionPart.setBody(caption.toUtf8()); + multiPart->append(captionPart); + + QHttpPart filePart; + filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip"); + filePart.setHeader(QNetworkRequest::ContentDispositionHeader, + R"(form-data; name="document"; filename="arc_events.zip")"); + filePart.setBodyDevice(sendFile); + sendFile->setParent(multiPart); + multiPart->append(filePart); + + auto *manager = new QNetworkAccessManager(this); + QNetworkReply *reply = manager->post(QNetworkRequest(url), multiPart); + multiPart->setParent(reply); + + connect(reply, &QNetworkReply::finished, this, [this, reply, manager, zipPath]() { + reply->deleteLater(); + manager->deleteLater(); + QFile::remove(zipPath); + + m_sendBtn->setEnabled(true); + m_sendBtn->setText("Отправить"); + + if (reply->error() != QNetworkReply::NoError) { + QMessageBox::warning(this, "Ошибка", "Не удалось отправить: " + reply->errorString()); + return; + } + + QMessageBox::information(this, "Готово", "События отправлены."); + }); +} diff --git a/views/event/EventWindow.h b/views/event/EventWindow.h index ca6fd99..79bae0c 100644 --- a/views/event/EventWindow.h +++ b/views/event/EventWindow.h @@ -17,6 +17,7 @@ private: QLabel *m_screenshotLabel; QPushButton *m_prevBtn; QPushButton *m_nextBtn; + QPushButton *m_sendBtn; int m_page = 0; int m_pageSize = 50; @@ -28,4 +29,5 @@ private: void loadPage(); void updatePagination(); void showScreenshot(int eventId); + void sendEventsToCsv(); }; diff --git a/views/log/FileLogWindow.h b/views/log/FileLogWindow.h index 82af8ba..0afa044 100644 --- a/views/log/FileLogWindow.h +++ b/views/log/FileLogWindow.h @@ -13,6 +13,7 @@ class FileLogWindow final : public QDialog { public: explicit FileLogWindow(QWidget *parent = nullptr); + void sendLogToTelegram(); private: QTableWidget *m_table; @@ -33,5 +34,4 @@ private: void loadPage(); void updatePagination(); void showScreenshot(int row); - void sendLogToTelegram(); }; diff --git a/views/widget/common/CollapsibleSection.cpp b/views/widget/common/CollapsibleSection.cpp index e1498be..faaaa6b 100644 --- a/views/widget/common/CollapsibleSection.cpp +++ b/views/widget/common/CollapsibleSection.cpp @@ -2,7 +2,9 @@ #include #include #include +#include #include +#include #include CollapsibleSection::CollapsibleSection( @@ -26,7 +28,20 @@ CollapsibleSection::CollapsibleSection( // Контент без собственного скрола, займёт доступное пространство m_contentBrowser = new QTextBrowser(this); - m_contentBrowser->setMarkdown(markdownContent); + m_contentBrowser->setSearchPaths({QCoreApplication::applicationDirPath() + "/tutorials"}); + if (markdownContent.contains(" + html.replace(QRegularExpression(R"(^## (.+)$)", QRegularExpression::MultilineOption), R"(

\1

)"); + // --- →
+ html.replace(QRegularExpression(R"(^---$)", QRegularExpression::MultilineOption), "
"); + // **text** → text + html.replace(QRegularExpression(R"(\*\*([^*]+)\*\*)"), R"(\1)"); + m_contentBrowser->setHtml(html); + } else { + m_contentBrowser->setMarkdown(markdownContent); + } m_contentBrowser->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_contentBrowser->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_contentBrowser->setFrameShape(QFrame::NoFrame);