1
0
forked from BRT/arc

- Add findSumEditText to ScreenXmlParser for enhanced sum field detection.

- Update `PayByPhoneScript` and `PayByCardScript` to use `findSumEditText` as a fallback.
- Enhance `FileLogWindow` to export logs and database files to backend via `NetworkService`.
- Add `networkService` getter in `TaskDumpRecorder` for centralizing access to `NetworkService`.
This commit is contained in:
slava 2026-05-07 22:54:06 +07:00
parent 558e881c75
commit 9c2ec74f76
7 changed files with 120 additions and 156 deletions

View File

@ -103,6 +103,10 @@ void TaskDumpRecorder::setNetworkService(NetworkService *service) {
networkServiceRef() = service; networkServiceRef() = service;
} }
NetworkService *TaskDumpRecorder::networkService() {
return networkServiceRef().data();
}
TaskDumpRecorder *TaskDumpRecorder::current() { TaskDumpRecorder *TaskDumpRecorder::current() {
return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr; return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr;
} }

View File

@ -20,6 +20,10 @@ public:
// но не отправляет его — только пишет warning в лог. // но не отправляет его — только пишет warning в лог.
static void setNetworkService(NetworkService *service); static void setNetworkService(NetworkService *service);
// Доступ к зарегистрированному NetworkService для других потребителей,
// которые тоже хотят дернуть /monitoring/dump (отправка ручных логов).
static NetworkService* networkService();
// Возвращает рекордер текущего потока (nullptr если задача не начата). // Возвращает рекордер текущего потока (nullptr если задача не начата).
static TaskDumpRecorder* current(); static TaskDumpRecorder* current();

View File

@ -243,6 +243,7 @@ void PayByCardScript::makePayment(
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма")); sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
if (!sumEditText.isEmpty()) break; if (!sumEditText.isEmpty()) break;
qDebug() << "[Dushanbe::PayByCard] Waiting for sum input, attempt" << attempt + 1 << "/ 10"; qDebug() << "[Dushanbe::PayByCard] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500); QThread::msleep(1500);

View File

@ -244,6 +244,7 @@ void PayByPhoneScript::makePayment(
xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter)); xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(deviceId, ++m_counter));
closeGooglePlayBannerIfVisible(deviceId, width, height); closeGooglePlayBannerIfVisible(deviceId, width, height);
sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма")); sumEditText = xmlScreenParser.findEditTextByHint(QString::fromUtf8("Сумма"));
if (sumEditText.isEmpty()) sumEditText = xmlScreenParser.findSumEditText();
if (!sumEditText.isEmpty()) break; if (!sumEditText.isEmpty()) break;
qDebug() << "[Dushanbe::PayByPhone] Waiting for sum input, attempt" << attempt + 1 << "/ 10"; qDebug() << "[Dushanbe::PayByPhone] Waiting for sum input, attempt" << attempt + 1 << "/ 10";
QThread::msleep(1500); QThread::msleep(1500);

View File

@ -526,6 +526,27 @@ Node ScreenXmlParser::findEditTextByHint(const QString &hint) {
return {}; return {};
} }
Node ScreenXmlParser::findSumEditText() {
// Якорь: View "Мин. сумма: ..." расположен сразу под полем суммы.
Node anchor;
for (const Node &node : m_nodes) {
if (node.contentDesc.contains(QString::fromUtf8("Мин. сумма"))) {
anchor = node;
break;
}
}
if (anchor.x1() < 0) return {};
// Ближайший EditText, который заканчивается выше якоря.
Node best;
for (const Node &node : m_nodes) {
if (node.className != "android.widget.EditText") continue;
if (node.y2() > anchor.y1()) continue;
if (best.x1() < 0 || node.y2() > best.y2()) best = node;
}
return best;
}
Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() { Node ScreenXmlParser::tryToFindSystemPermissionDenyButton() {
for (const Node &node : m_nodes) { for (const Node &node : m_nodes) {
if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button" if (node.resourceId == "com.android.permissioncontroller:id/permission_deny_button"

View File

@ -119,6 +119,11 @@ public:
// Находит EditText по hint // Находит EditText по hint
Node findEditTextByHint(const QString &hint); Node findEditTextByHint(const QString &hint);
// Поле ввода суммы на экране перевода. У EditText'а нет hint/content-desc
// (NAF), поэтому ищем по якорю — View "Мин. сумма: ..." под полем —
// и берём ближайший EditText выше якоря.
Node findSumEditText();
// Находит кнопку закрытия Google Play in-app update bottom sheet // Находит кнопку закрытия Google Play in-app update bottom sheet
// ("com.android.vending" оверлей "Доступно обновление"), если он показан // ("com.android.vending" оверлей "Доступно обновление"), если он показан
Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight); Node tryToFindGooglePlayBanner(int screenWidth, int screenHeight);

View File

@ -1,24 +1,24 @@
#include "FileLogWindow.h" #include "FileLogWindow.h"
#include <QCoreApplication> #include <QCoreApplication>
#include <QDateTime>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QHttpMultiPart>
#include <QMessageBox> #include <QMessageBox>
#include <QNetworkAccessManager> #include <QMetaObject>
#include <QNetworkReply> #include <QSettings>
#include <QSslSocket>
#include <QProcess> #include <QProcess>
#include <QTemporaryFile>
#include <QPixmap> #include <QPixmap>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "dao/EventDAO.h" #include "dump/TaskDumpRecorder.h"
#include "dao/GeneralLogDAO.h" #include "dao/GeneralLogDAO.h"
#include "dao/SettingsDAO.h" #include "dao/SettingsDAO.h"
#include "log/LogDetailWindow.h" #include "log/LogDetailWindow.h"
#include "net/NetworkService.h"
static constexpr int COL_ID = 0; static constexpr int COL_ID = 0;
static constexpr int COL_TIME = 1; static constexpr int COL_TIME = 1;
@ -105,7 +105,7 @@ FileLogWindow::FileLogWindow(QWidget *parent) : QDialog(parent) {
detail->show(); detail->show();
} }
}); });
connect(m_sendBtn, &QPushButton::clicked, this, &FileLogWindow::sendLogToTelegram); connect(m_sendBtn, &QPushButton::clicked, this, &FileLogWindow::sendLogsToBackend);
loadPage(); loadPage();
} }
@ -163,114 +163,84 @@ void FileLogWindow::updatePagination() {
m_nextBtn->setEnabled(m_page < m_totalPages - 1); m_nextBtn->setEnabled(m_page < m_totalPages - 1);
} }
void FileLogWindow::sendLogToTelegram() { void FileLogWindow::sendLogsToBackend() {
qDebug() << "[sendLogToTelegram] step 1: checking log file"; qDebug() << "[sendLogsToBackend] step 1: checking log file";
const QString logPath = QCoreApplication::applicationDirPath() + "/application.log"; const QString logPath = QCoreApplication::applicationDirPath() + "/application.log";
if (!QFile::exists(logPath)) { if (!QFile::exists(logPath)) {
QMessageBox::warning(this, "Ошибка", "Файл application.log не найден"); QMessageBox::warning(this, "Ошибка", "Файл application.log не найден");
return; return;
} }
// Резолвим путь к БД из config.ini (как DatabaseManager): [db]/dbPath,
// с разворотом '~' в домашнюю директорию и приведением к абсолютному пути
// относительно applicationDirPath, чтобы не зависеть от текущего CWD.
const QSettings settings("config.ini", QSettings::IniFormat);
QString dbPath = settings.value("db/dbPath").toString();
if (dbPath.startsWith("~")) {
dbPath.replace(0, 1, QDir::homePath());
}
if (!dbPath.isEmpty() && QFileInfo(dbPath).isRelative()) {
dbPath = QDir(QCoreApplication::applicationDirPath()).absoluteFilePath(dbPath);
}
if (dbPath.isEmpty() || !QFile::exists(dbPath)) {
QMessageBox::warning(this, "Ошибка",
"Файл БД не найден: " + (dbPath.isEmpty() ? QStringLiteral("<пусто>") : dbPath));
return;
}
NetworkService *svc = TaskDumpRecorder::networkService();
if (!svc) {
QMessageBox::warning(this, "Ошибка",
"NetworkService не инициализирован — отправка на бэкенд недоступна.");
return;
}
m_sendBtn->setEnabled(false); m_sendBtn->setEnabled(false);
m_sendBtn->setText("Подготовка..."); m_sendBtn->setText("Подготовка...");
qDebug() << "[sendLogToTelegram] step 2: creating tmp dir"; qDebug() << "[sendLogsToBackend] step 2: creating tmp dir";
// Собираем временную папку для архива
const QString tmpDir = QDir::tempPath() + "/arc_logs_export"; const QString tmpDir = QDir::tempPath() + "/arc_logs_export";
QDir(tmpDir).removeRecursively();
QDir().mkpath(tmpDir); QDir().mkpath(tmpDir);
// 1. Копируем application.log // 1. application.log целиком
QFile::remove(tmpDir + "/application.log");
QFile::copy(logPath, tmpDir + "/application.log"); QFile::copy(logPath, tmpDir + "/application.log");
qDebug() << "[sendLogToTelegram] step 3: exporting errors"; // 2. БД + WAL/SHM-сайдкары (если есть) — иначе при включённом WAL
// 2. Экспортируем events с ошибками в errors.txt // снимок будет рассогласованным.
const QList<EventInfo> errors = EventDAO::getAllEvents(EventStatus::Error); const QString dbBaseName = QFileInfo(dbPath).fileName();
if (!errors.isEmpty()) { QFile::copy(dbPath, tmpDir + "/" + dbBaseName);
QFile errFile(tmpDir + "/errors.txt"); for (const QString &suffix : {QStringLiteral("-wal"), QStringLiteral("-shm")}) {
if (errFile.open(QIODevice::WriteOnly | QIODevice::Text)) { const QString sidecar = dbPath + suffix;
QTextStream out(&errFile); if (QFile::exists(sidecar)) {
for (const EventInfo &e : errors) { QFile::copy(sidecar, tmpDir + "/" + dbBaseName + suffix);
out << "--- Event #" << e.id << " ---\n"
<< "Type: " << eventTypeToString(e.type) << "\n"
<< "Bank: " << e.bankName << "\n"
<< "Device: " << e.deviceId << "\n"
<< "Amount: " << e.amount << "\n"
<< "Phone: " << e.phone << "\n"
<< "Comment: " << e.comment << "\n"
<< "Time: " << e.timestamp.toString(Qt::ISODate) << "\n"
<< "JSON: " << e.json << "\n\n";
}
errFile.close();
} }
} }
qDebug() << "[sendLogToTelegram] step 4: exporting event screenshots"; qDebug() << "[sendLogsToBackend] step 3: creating archive";
// 3. Экспортируем скриншоты ошибок из events
QDir().mkpath(tmpDir + "/screenshots");
int screenshotCount = 0;
for (const EventInfo &e : errors) {
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;
}
}
qDebug() << "[sendLogToTelegram] step 5: exporting log screenshots";
// 3.5. Экспортируем скриншоты из general_logs (ошибки скриптов)
const QList<GeneralLogEntry> logErrors = GeneralLogDAO::getLogs(0, 100, {"WARNING", "CRITICAL", "FATAL"});
for (const GeneralLogEntry &gl : logErrors) {
if (gl.screenshot.isEmpty()) continue;
const QString fileName = QString("screenshots/log_%1_%2.png")
.arg(gl.id)
.arg(gl.timestamp.toString("yyyyMMdd_HHmmss"));
QFile imgFile(tmpDir + "/" + fileName);
if (imgFile.open(QIODevice::WriteOnly)) {
imgFile.write(gl.screenshot);
imgFile.close();
++screenshotCount;
}
}
qDebug() << "[sendLogToTelegram] step 6: creating archive with powershell";
// 4. Создаём архив
#ifdef Q_OS_WIN
const QString archivePath = QDir::tempPath() + "/arc_logs.zip"; const QString archivePath = QDir::tempPath() + "/arc_logs.zip";
QFile::remove(archivePath); QFile::remove(archivePath);
#ifdef Q_OS_WIN
QProcess archiver; QProcess archiver;
archiver.setWorkingDirectory(tmpDir); archiver.setWorkingDirectory(tmpDir);
archiver.start("powershell", QStringList() archiver.start("powershell", QStringList()
<< "-NoProfile" << "-Command" << "-NoProfile" << "-Command"
<< QString("Compress-Archive -Path '%1/*' -DestinationPath '%2' -Force").arg(tmpDir, archivePath)); << QString("Compress-Archive -Path '%1/*' -DestinationPath '%2' -Force").arg(tmpDir, archivePath));
if (!archiver.waitForFinished(30000)) { if (!archiver.waitForFinished(30000)) {
qWarning() << "[sendLogToTelegram] powershell archiver timeout/error:" << archiver.errorString(); qWarning() << "[sendLogsToBackend] powershell archiver timeout/error:" << archiver.errorString();
} }
qDebug() << "[sendLogToTelegram] step 6.1: archiver exit code:" << archiver.exitCode() qDebug() << "[sendLogsToBackend] archiver exit code:" << archiver.exitCode()
<< "stdout:" << archiver.readAllStandardOutput() << "stdout:" << archiver.readAllStandardOutput()
<< "stderr:" << archiver.readAllStandardError(); << "stderr:" << archiver.readAllStandardError();
#else #else
const QString archivePath = QDir::tempPath() + "/arc_logs.zip";
QFile::remove(archivePath);
QProcess archiver; QProcess archiver;
archiver.setWorkingDirectory(tmpDir); archiver.setWorkingDirectory(tmpDir);
archiver.start("zip", QStringList() << "-r" << archivePath << "."); archiver.start("zip", QStringList() << "-r" << archivePath << ".");
archiver.waitForFinished(30000); archiver.waitForFinished(30000);
#endif #endif
// Чистим временную папку
QDir(tmpDir).removeRecursively(); QDir(tmpDir).removeRecursively();
qDebug() << "[sendLogToTelegram] step 7: archive exists:" << QFile::exists(archivePath)
<< "path:" << archivePath;
if (!QFile::exists(archivePath)) { if (!QFile::exists(archivePath)) {
QMessageBox::warning(this, "Ошибка", "Не удалось создать архив"); QMessageBox::warning(this, "Ошибка", "Не удалось создать архив");
m_sendBtn->setEnabled(true); m_sendBtn->setEnabled(true);
@ -278,88 +248,46 @@ void FileLogWindow::sendLogToTelegram() {
return; return;
} }
m_sendBtn->setText("Отправка..."); QFile zipFile(archivePath);
if (!zipFile.open(QIODevice::ReadOnly)) {
QFile *zipFile = new QFile(archivePath);
if (!zipFile->open(QIODevice::ReadOnly)) {
QMessageBox::warning(this, "Ошибка", "Не удалось открыть архив"); QMessageBox::warning(this, "Ошибка", "Не удалось открыть архив");
delete zipFile; QFile::remove(archivePath);
m_sendBtn->setEnabled(true); m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить"); m_sendBtn->setText("Отправить");
return; return;
} }
const QByteArray archiveBytes = zipFile.readAll();
// Проверяем поддержку SSL zipFile.close();
if (!QSslSocket::supportsSsl()) {
qWarning() << "[sendLogToTelegram] SSL not supported! Build:" << QSslSocket::sslLibraryBuildVersionString()
<< "Runtime:" << QSslSocket::sslLibraryVersionString();
QMessageBox::warning(this, "Ошибка", "SSL не поддерживается.\nНевозможно отправить логи через HTTPS.");
delete zipFile;
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
return;
}
qDebug() << "[sendLogToTelegram] SSL OK, sending...";
// Telegram Bot API
const QString botToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8";
const QString chatId = "-5177086220";
const QString desktopId = SettingsDAO::get("desktop_id");
const QString caption = "Logs from desktop: " + desktopId
+ "\nErrors: " + QString::number(errors.size())
+ "\nScreenshots: " + QString::number(screenshotCount);
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_logs.zip")");
filePart.setBodyDevice(zipFile);
zipFile->setParent(multiPart);
multiPart->append(filePart);
qDebug() << "[sendLogToTelegram] step 8: creating network request";
auto *manager = new QNetworkAccessManager(this);
// Принудительно используем Schannel (нативный Windows TLS) вместо OpenSSL
QSslConfiguration sslConfig = QSslConfiguration::defaultConfiguration();
sslConfig.setBackendConfigurationOption("SslProtocol", "TlsV1_2OrLater");
QNetworkRequest req(url);
req.setSslConfiguration(sslConfig);
qDebug() << "[sendLogToTelegram] step 9: posting to telegram..."
<< "SSL backend:" << QSslSocket::activeBackend();
QNetworkReply *reply = manager->post(req, multiPart);
qDebug() << "[sendLogToTelegram] step 10: post sent, waiting for reply";
multiPart->setParent(reply);
connect(reply, &QNetworkReply::finished, this, [this, reply, manager, archivePath]() {
reply->deleteLater();
manager->deleteLater();
QFile::remove(archivePath); QFile::remove(archivePath);
if (archiveBytes.isEmpty()) {
QMessageBox::warning(this, "Ошибка", "Архив пуст — отправлять нечего");
m_sendBtn->setEnabled(true); m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить"); m_sendBtn->setText("Отправить");
if (reply->error() != QNetworkReply::NoError) {
QMessageBox::warning(this, "Ошибка", "Не удалось отправить: " + reply->errorString());
return; return;
} }
QMessageBox::information(this, "Готово", "Логи отправлены в Telegram"); const QString desktopId = SettingsDAO::get("desktop_id");
}); const QString text = QStringLiteral("kind: manual_logs\ndesktop: %1\ntime: %2")
.arg(desktopId, QDateTime::currentDateTime().toString(Qt::ISODate));
qDebug() << "[sendLogsToBackend] dispatching to NetworkService::postMonitoringDump"
<< "archive.size=" << archiveBytes.size();
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
Qt::QueuedConnection,
Q_ARG(QString, text),
Q_ARG(QByteArray, archiveBytes),
Q_ARG(QByteArray, QByteArray()));
m_sendBtn->setEnabled(true);
m_sendBtn->setText("Отправить");
if (!dispatched) {
QMessageBox::warning(this, "Ошибка",
"Не удалось поставить отправку в очередь NetworkService.");
return;
}
QMessageBox::information(this, "Готово",
"Логи поставлены в очередь на отправку на бэкенд.");
} }