Add OCR utilities and backend integration for enhanced screen dump analysis:
- Introduce `OcrUtils` with fuzzy-matching logic for text recognition on screenshots. - Implement dual-pass OCR to handle light-on-dark and dark-on-light text cases. - Replace hardcoded "Подтверждаю" button tap logic with OCR-based detection and fallback geometry. - Update `TaskDumpRecorder` to utilize `NetworkService` for uploading screen dumps, images, and metadata to the backend. - Add support for `/monitoring/dump` endpoint in `NetworkService`. - Enhance UI functionality with new error handling and progress feedback during dump creation and upload.
This commit is contained in:
parent
d42a58d69e
commit
c79acee002
@ -4,14 +4,18 @@
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QMetaObject>
|
||||
#include <QPointer>
|
||||
#include <QProcess>
|
||||
#include <QRegularExpression>
|
||||
#include <QStandardPaths>
|
||||
#include <QTextStream>
|
||||
#include <QThread>
|
||||
#include <QThreadStorage>
|
||||
#include <qDebug>
|
||||
|
||||
#include "adb/AdbUtils.h"
|
||||
#include "net/NetworkService.h"
|
||||
|
||||
namespace {
|
||||
|
||||
@ -20,11 +24,16 @@ QThreadStorage<TaskDumpRecorder *> &tlsRecorder() {
|
||||
return tls;
|
||||
}
|
||||
|
||||
constexpr const char *kBotToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8";
|
||||
constexpr const char *kChatId = "-5177086220";
|
||||
QPointer<NetworkService> &networkServiceRef() {
|
||||
static QPointer<NetworkService> ref;
|
||||
return ref;
|
||||
}
|
||||
|
||||
QString dumpsRoot() {
|
||||
return QCoreApplication::applicationDirPath() + "/dumps";
|
||||
// Пишем в пользовательский temp — UAC/Program Files не мешают.
|
||||
QString base = QStandardPaths::writableLocation(QStandardPaths::TempLocation);
|
||||
if (base.isEmpty()) base = QDir::tempPath();
|
||||
return base + "/arc_dumps";
|
||||
}
|
||||
|
||||
QString sanitize(const QString &raw, int maxLen = 40) {
|
||||
@ -50,33 +59,23 @@ QString extractScreenTag(const QString &xml) {
|
||||
return QStringLiteral("screen");
|
||||
}
|
||||
|
||||
bool runSync(const QString &program, const QStringList &args, int timeoutMs = 180000) {
|
||||
QProcess p;
|
||||
p.start(program, args);
|
||||
if (!p.waitForStarted(5000)) {
|
||||
qWarning() << "[TaskDumpRecorder] failed to start:" << program << args
|
||||
<< p.errorString();
|
||||
return false;
|
||||
}
|
||||
if (!p.waitForFinished(timeoutMs)) {
|
||||
qWarning() << "[TaskDumpRecorder] timeout:" << program << args;
|
||||
p.kill();
|
||||
return false;
|
||||
}
|
||||
if (p.exitCode() != 0) {
|
||||
qWarning() << "[TaskDumpRecorder]" << program << "exit=" << p.exitCode()
|
||||
<< "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool zipFolder(const QString &folder, const QString &archivePath) {
|
||||
#ifdef Q_OS_WIN
|
||||
const QString cmd = QString(
|
||||
"Compress-Archive -Path '%1\\*' -DestinationPath '%2' -Force"
|
||||
).arg(QDir::toNativeSeparators(folder), QDir::toNativeSeparators(archivePath));
|
||||
return runSync("powershell", {"-NoProfile", "-Command", cmd});
|
||||
QProcess p;
|
||||
p.start("powershell", {"-NoProfile", "-Command", cmd});
|
||||
if (!p.waitForStarted(5000) || !p.waitForFinished(180000)) {
|
||||
qWarning() << "[TaskDumpRecorder] zip failed:" << p.errorString();
|
||||
return false;
|
||||
}
|
||||
if (p.exitCode() != 0) {
|
||||
qWarning() << "[TaskDumpRecorder] zip exit=" << p.exitCode()
|
||||
<< "stderr:" << QString::fromUtf8(p.readAllStandardError()).trimmed();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
#else
|
||||
const QFileInfo fi(folder);
|
||||
QProcess p;
|
||||
@ -90,20 +89,20 @@ bool zipFolder(const QString &folder, const QString &archivePath) {
|
||||
#endif
|
||||
}
|
||||
|
||||
void uploadToTelegram(const QString &archivePath, const QString &caption) {
|
||||
const QString url = QString("https://api.telegram.org/bot%1/sendDocument").arg(kBotToken);
|
||||
const QStringList args = {
|
||||
"-sS", "--max-time", "120",
|
||||
"-F", QString("chat_id=%1").arg(kChatId),
|
||||
"-F", QString("document=@%1").arg(archivePath),
|
||||
"-F", QString("caption=%1").arg(caption),
|
||||
url
|
||||
};
|
||||
runSync("curl", args, 150000);
|
||||
QByteArray readFileBytes(const QString &path) {
|
||||
QFile f(path);
|
||||
if (!f.open(QIODevice::ReadOnly)) return {};
|
||||
QByteArray bytes = f.readAll();
|
||||
f.close();
|
||||
return bytes;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void TaskDumpRecorder::setNetworkService(NetworkService *service) {
|
||||
networkServiceRef() = service;
|
||||
}
|
||||
|
||||
TaskDumpRecorder *TaskDumpRecorder::current() {
|
||||
return tlsRecorder().hasLocalData() ? tlsRecorder().localData() : nullptr;
|
||||
}
|
||||
@ -234,16 +233,50 @@ void TaskDumpRecorder::finishInternal(const QString &errorMessage) {
|
||||
|
||||
const QString folder = m_folder;
|
||||
const QString archivePath = m_folder + ".zip";
|
||||
const QString caption = buildCaption(errorMessage);
|
||||
const QString text = buildCaption(errorMessage);
|
||||
|
||||
QThread *bg = QThread::create([folder, archivePath, caption]() {
|
||||
// Финальный скриншот шлём отдельным полем `image`, поэтому сразу читаем
|
||||
// его в память, пока папка ещё цела.
|
||||
QByteArray finalScreenshot;
|
||||
{
|
||||
const QString screenPath = folder + "/_final_screen.png";
|
||||
if (QFile::exists(screenPath)) {
|
||||
finalScreenshot = readFileBytes(screenPath);
|
||||
}
|
||||
}
|
||||
|
||||
QThread *bg = QThread::create([folder, archivePath, text, finalScreenshot]() {
|
||||
QByteArray archiveBytes;
|
||||
if (zipFolder(folder, archivePath)) {
|
||||
uploadToTelegram(archivePath, caption);
|
||||
archiveBytes = readFileBytes(archivePath);
|
||||
if (archiveBytes.isEmpty()) {
|
||||
qWarning() << "[TaskDumpRecorder] zip is empty, sending text-only dump for" << folder;
|
||||
}
|
||||
} else {
|
||||
qWarning() << "[TaskDumpRecorder] zip failed, skipping Telegram upload for" << folder;
|
||||
qWarning() << "[TaskDumpRecorder] zip failed, sending text-only dump for" << folder;
|
||||
}
|
||||
QDir(folder).removeRecursively();
|
||||
QFile::remove(archivePath);
|
||||
|
||||
NetworkService *svc = networkServiceRef().data();
|
||||
if (!svc) {
|
||||
qWarning() << "[TaskDumpRecorder] no NetworkService — dump dropped for" << folder
|
||||
<< "(setNetworkService was not called at app startup?)";
|
||||
return;
|
||||
}
|
||||
qDebug() << "[TaskDumpRecorder] dispatching dump to NetworkService"
|
||||
<< "archive.size=" << archiveBytes.size()
|
||||
<< "image.size=" << finalScreenshot.size()
|
||||
<< "text.size=" << text.size();
|
||||
const bool dispatched = QMetaObject::invokeMethod(svc, "postMonitoringDump",
|
||||
Qt::QueuedConnection,
|
||||
Q_ARG(QString, text),
|
||||
Q_ARG(QByteArray, archiveBytes),
|
||||
Q_ARG(QByteArray, finalScreenshot));
|
||||
if (!dispatched) {
|
||||
qCritical() << "[TaskDumpRecorder] invokeMethod(postMonitoringDump) FAILED — "
|
||||
"slot not found or wrong signature for" << folder;
|
||||
}
|
||||
});
|
||||
QObject::connect(bg, &QThread::finished, bg, &QObject::deleteLater);
|
||||
bg->start();
|
||||
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
#include <QString>
|
||||
|
||||
// Мета-поля задачи. Пустые/нулевые значения не попадают в caption Telegram
|
||||
class NetworkService;
|
||||
|
||||
// Мета-поля задачи. Пустые/нулевые значения не попадают в text дампа
|
||||
// и в имя папки — выводятся только те, что релевантны конкретному типу задачи.
|
||||
struct TaskDumpMeta {
|
||||
QString materialId;
|
||||
@ -13,6 +15,11 @@ struct TaskDumpMeta {
|
||||
|
||||
class TaskDumpRecorder {
|
||||
public:
|
||||
// NetworkService, через который дамп отправляется на бэкенд (/monitoring/dump).
|
||||
// Вызывать один раз при старте приложения. Без него рекордер собирает дамп,
|
||||
// но не отправляет его — только пишет warning в лог.
|
||||
static void setNetworkService(NetworkService *service);
|
||||
|
||||
// Возвращает рекордер текущего потока (nullptr если задача не начата).
|
||||
static TaskDumpRecorder* current();
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
#include "adb/AdbUtils.h"
|
||||
#include "dump/TaskDumpRecorder.h"
|
||||
#include "ocr/OcrUtils.h"
|
||||
#include "dao/BankProfileDAO.h"
|
||||
#include "dao/DeviceDAO.h"
|
||||
#include "dao/TransactionDAO.h"
|
||||
@ -353,11 +354,21 @@ void PayByCardScript::makePayment(
|
||||
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);
|
||||
// Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
|
||||
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
|
||||
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
|
||||
// нижней четверти sheet, выше системного nav-бара).
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
confirmNode.y1(), confirmNode.y2());
|
||||
if (tap.x() < 0) {
|
||||
const int tapY = confirmNode.y2() - qMax(150, height / 14);
|
||||
tap = QPoint(confirmNode.x(), tapY);
|
||||
qDebug() << "[Dushanbe::PayByCard] OCR miss, fallback tap at" << tap;
|
||||
}
|
||||
qDebug() << "[Dushanbe::PayByCard] Tapping 'Подтверждаю' at" << tap;
|
||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||
confirmed = true;
|
||||
QThread::msleep(4000);
|
||||
break;
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
|
||||
#include "adb/AdbUtils.h"
|
||||
#include "dump/TaskDumpRecorder.h"
|
||||
#include "ocr/OcrUtils.h"
|
||||
#include "dao/BankProfileDAO.h"
|
||||
#include "dao/DeviceDAO.h"
|
||||
#include "dao/TransactionDAO.h"
|
||||
@ -349,10 +350,21 @@ void PayByPhoneScript::makePayment(
|
||||
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);
|
||||
// Compose flatten-ит весь bottom sheet в одну accessibility-кнопку,
|
||||
// отдельного узла "Подтверждаю" в дампе нет — координаты берём OCR-ом
|
||||
// по скриншоту. Если OCR не нашёл — fallback на геометрию (центр
|
||||
// нижней четверти sheet, выше системного nav-бара).
|
||||
const QByteArray screen = AdbUtils::captureScreenshotBytes(deviceId);
|
||||
QPoint tap = OcrUtils::findTextCenter(screen,
|
||||
QString::fromUtf8("Подтверждаю"),
|
||||
confirmNode.y1(), confirmNode.y2());
|
||||
if (tap.x() < 0) {
|
||||
const int tapY = confirmNode.y2() - qMax(150, height / 14);
|
||||
tap = QPoint(confirmNode.x(), tapY);
|
||||
qDebug() << "[Dushanbe::PayByPhone] OCR miss, fallback tap at" << tap;
|
||||
}
|
||||
qDebug() << "[Dushanbe::PayByPhone] Tapping 'Подтверждаю' at" << tap;
|
||||
AdbUtils::makeTap(deviceId, tap.x(), tap.y());
|
||||
confirmed = true;
|
||||
QThread::msleep(4000);
|
||||
break;
|
||||
|
||||
153
android/ocr/OcrUtils.cpp
Normal file
153
android/ocr/OcrUtils.cpp
Normal file
@ -0,0 +1,153 @@
|
||||
#include "OcrUtils.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QElapsedTimer>
|
||||
|
||||
#include <tesseract/baseapi.h>
|
||||
#include <leptonica/allheaders.h>
|
||||
|
||||
namespace {
|
||||
|
||||
// Расстояние Левенштейна на QString — нужен для нечёткого сравнения OCR-вывода
|
||||
// с искомым словом. Для "Подтверждаю" допускаем 1-2 ошибки.
|
||||
int editDistance(const QString &a, const QString &b) {
|
||||
const int n = a.size();
|
||||
const int m = b.size();
|
||||
if (n == 0) return m;
|
||||
if (m == 0) return n;
|
||||
QVector<int> prev(m + 1), cur(m + 1);
|
||||
for (int j = 0; j <= m; ++j) prev[j] = j;
|
||||
for (int i = 1; i <= n; ++i) {
|
||||
cur[0] = i;
|
||||
for (int j = 1; j <= m; ++j) {
|
||||
const int cost = a[i - 1] == b[j - 1] ? 0 : 1;
|
||||
cur[j] = qMin(qMin(cur[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
|
||||
}
|
||||
prev.swap(cur);
|
||||
}
|
||||
return prev[m];
|
||||
}
|
||||
|
||||
bool fuzzyMatch(const QString &word, const QString &needle) {
|
||||
if (word.isEmpty() || needle.isEmpty()) return false;
|
||||
const QString w = word.toLower();
|
||||
const QString n = needle.toLower();
|
||||
if (w.contains(n) || n.contains(w)) return true;
|
||||
// Допускаем до 20% ошибок (но не меньше 1) — Tesseract на «Подтверждаю» иногда
|
||||
// даёт «Подтверждак»/«Подтверждак)» из-за оранжевого фона и сглаживания.
|
||||
const int tolerance = qMax(1, n.size() / 5);
|
||||
return editDistance(w, n) <= tolerance;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace OcrUtils {
|
||||
|
||||
QPoint findTextCenter(const QByteArray &png,
|
||||
const QString &needle,
|
||||
int yMin,
|
||||
int yMax) {
|
||||
if (png.isEmpty() || needle.isEmpty()) return {-1, -1};
|
||||
|
||||
QElapsedTimer timer;
|
||||
timer.start();
|
||||
|
||||
Pix *pix = pixReadMem(reinterpret_cast<const l_uint8 *>(png.constData()),
|
||||
static_cast<size_t>(png.size()));
|
||||
if (!pix) {
|
||||
qWarning() << "[OcrUtils] pixReadMem failed";
|
||||
return {-1, -1};
|
||||
}
|
||||
|
||||
const int fullW = pixGetWidth(pix);
|
||||
const int fullH = pixGetHeight(pix);
|
||||
|
||||
int cropY = 0;
|
||||
Pix *target = pix;
|
||||
Pix *cropped = nullptr;
|
||||
if (yMin > 0 || (yMax > 0 && yMax < fullH)) {
|
||||
const int y0 = qMax(0, yMin);
|
||||
const int y1 = (yMax > 0 && yMax <= fullH) ? yMax : fullH;
|
||||
if (y1 > y0) {
|
||||
BOX *box = boxCreate(0, y0, fullW, y1 - y0);
|
||||
cropped = pixClipRectangle(pix, box, nullptr);
|
||||
boxDestroy(&box);
|
||||
if (cropped) {
|
||||
target = cropped;
|
||||
cropY = y0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Гоняем OCR двумя проходами: оригинал + инверсия. Текст бывает разный —
|
||||
// тёмный на светлом (заголовки/поля) и светлый на цветном (sticky-action
|
||||
// кнопка, оранжевая/синяя). Инверсия белого-на-оранжевом превращает её в
|
||||
// тёмное-на-голубом, что Tesseract разбирает на порядок надёжнее.
|
||||
auto runPass = [&](Pix *src, bool invert) -> QPoint {
|
||||
Pix *gray = pixConvertTo8(src, 0);
|
||||
if (!gray) return {-1, -1};
|
||||
Pix *prepared = gray;
|
||||
Pix *inverted = nullptr;
|
||||
if (invert) {
|
||||
inverted = pixInvert(nullptr, gray);
|
||||
if (inverted) prepared = inverted;
|
||||
}
|
||||
|
||||
auto *api = new tesseract::TessBaseAPI();
|
||||
if (api->Init(QCoreApplication::applicationDirPath().toUtf8().constData(), "rus")) {
|
||||
qWarning() << "[OcrUtils] Tesseract init failed (tessdata in"
|
||||
<< QCoreApplication::applicationDirPath() << ")";
|
||||
delete api;
|
||||
if (inverted) pixDestroy(&inverted);
|
||||
pixDestroy(&gray);
|
||||
return {-1, -1};
|
||||
}
|
||||
api->SetImage(prepared);
|
||||
api->Recognize(nullptr);
|
||||
|
||||
QPoint hit(-1, -1);
|
||||
tesseract::ResultIterator *ri = api->GetIterator();
|
||||
if (ri) {
|
||||
do {
|
||||
const char *raw = ri->GetUTF8Text(tesseract::RIL_WORD);
|
||||
if (!raw) continue;
|
||||
const QString word = QString::fromUtf8(raw).trimmed();
|
||||
delete[] raw;
|
||||
if (!fuzzyMatch(word, needle)) continue;
|
||||
|
||||
int x1, y1, x2, y2;
|
||||
ri->BoundingBox(tesseract::RIL_WORD, &x1, &y1, &x2, &y2);
|
||||
const int cx = (x1 + x2) / 2;
|
||||
const int cy = (y1 + y2) / 2 + cropY;
|
||||
qDebug() << "[OcrUtils] found" << needle << "→" << word
|
||||
<< (invert ? "[inverted]" : "[normal]")
|
||||
<< "at (" << cx << "," << cy << ")";
|
||||
hit = QPoint(cx, cy);
|
||||
break;
|
||||
} while (ri->Next(tesseract::RIL_WORD));
|
||||
}
|
||||
api->End();
|
||||
delete api;
|
||||
if (inverted) pixDestroy(&inverted);
|
||||
pixDestroy(&gray);
|
||||
return hit;
|
||||
};
|
||||
|
||||
QPoint result = runPass(target, /*invert=*/false);
|
||||
if (result.x() < 0) {
|
||||
result = runPass(target, /*invert=*/true);
|
||||
}
|
||||
|
||||
if (cropped) pixDestroy(&cropped);
|
||||
pixDestroy(&pix);
|
||||
|
||||
if (result.x() < 0) {
|
||||
qDebug() << "[OcrUtils]" << needle << "not found in" << timer.elapsed() << "ms";
|
||||
} else {
|
||||
qDebug() << "[OcrUtils]" << needle << "located in" << timer.elapsed() << "ms";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
} // namespace OcrUtils
|
||||
22
android/ocr/OcrUtils.h
Normal file
22
android/ocr/OcrUtils.h
Normal file
@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QPoint>
|
||||
#include <QString>
|
||||
|
||||
namespace OcrUtils {
|
||||
|
||||
// Находит надпись `needle` на скриншоте (PNG-байты) и возвращает координаты
|
||||
// центра bounding box-а в системе координат экрана. Если текст не найден —
|
||||
// возвращает QPoint(-1, -1). Совпадение нечувствительно к регистру и допускает
|
||||
// одну-две OCR-ошибки в букве (для "Подтверждаю" на оранжевом фоне это важно).
|
||||
//
|
||||
// `yMin`/`yMax` — необязательное окно по Y в координатах экрана. Если задано,
|
||||
// OCR гоняется только по этой полосе, что в 2-3 раза быстрее на 1080×2340.
|
||||
// 0/0 — обрабатывать всё изображение.
|
||||
QPoint findTextCenter(const QByteArray &png,
|
||||
const QString &needle,
|
||||
int yMin = 0,
|
||||
int yMax = 0);
|
||||
|
||||
} // namespace OcrUtils
|
||||
2
main.cpp
2
main.cpp
@ -50,6 +50,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) {
|
||||
#include <QSettings>
|
||||
|
||||
#include "AppLogger.h"
|
||||
#include "dump/TaskDumpRecorder.h"
|
||||
#include "dao/GeneralLogDAO.h"
|
||||
#include "dao/SettingsDAO.h"
|
||||
#include "EventHandler.h"
|
||||
@ -168,6 +169,7 @@ void startNetworkService(const QCoreApplication &app) {
|
||||
auto *paymentThread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
AppLogger::setNetworkService(networkService);
|
||||
TaskDumpRecorder::setNetworkService(networkService);
|
||||
networkService->moveToThread(paymentThread);
|
||||
QObject::connect(paymentThread, &QThread::started, networkService, &NetworkService::start);
|
||||
QObject::connect(networkService, &NetworkService::finished, paymentThread, &QThread::quit);
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
#include <QUrlQuery>
|
||||
#include <QHttpPart>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QThread>
|
||||
@ -60,6 +61,7 @@ NetworkService::NetworkService(QObject *parent) : QObject(parent) {
|
||||
m_pathGetTasks = settings.value("net/pathGetTasks", "/api/v1/task/get_tasks").toString();
|
||||
m_pathTaskResult = settings.value("net/pathTaskResult", "/api/v1/task/task_result").toString();
|
||||
m_pathMonitoringLog = settings.value("net/pathMonitoringLog", "/monitoring/log").toString();
|
||||
m_pathMonitoringDump = settings.value("net/pathMonitoringDump", "/monitoring/dump").toString();
|
||||
m_pathCurrentProfile = settings.value("net/pathCurrentProfile", "/api/v1/profile/current_profile").toString();
|
||||
m_manager = new QNetworkAccessManager(this);
|
||||
m_token = settings.value("common/token").toString();
|
||||
@ -1363,6 +1365,74 @@ void NetworkService::postMonitoringLog(const QString &type, const QString &event
|
||||
reply->deleteLater();
|
||||
}
|
||||
|
||||
void NetworkService::postMonitoringDump(const QString &text, const QByteArray &archive,
|
||||
const QByteArray &image) {
|
||||
qDebug() << "[NetworkService] postMonitoringDump enter — text.size=" << text.size()
|
||||
<< "archive.size=" << archive.size() << "image.size=" << image.size();
|
||||
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
|
||||
addFormField(multiPart, "text", text);
|
||||
|
||||
if (!archive.isEmpty()) {
|
||||
QHttpPart filePart;
|
||||
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/zip");
|
||||
filePart.setHeader(
|
||||
QNetworkRequest::ContentDispositionHeader,
|
||||
QStringLiteral("form-data; name=\"file\"; filename=\"task_dump.zip\"")
|
||||
);
|
||||
filePart.setBody(archive);
|
||||
multiPart->append(filePart);
|
||||
}
|
||||
|
||||
if (!image.isEmpty()) {
|
||||
QHttpPart imagePart;
|
||||
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/png");
|
||||
imagePart.setHeader(
|
||||
QNetworkRequest::ContentDispositionHeader,
|
||||
QStringLiteral("form-data; name=\"image\"; filename=\"final_screen.png\"")
|
||||
);
|
||||
imagePart.setBody(image);
|
||||
multiPart->append(imagePart);
|
||||
}
|
||||
|
||||
ensureValidToken();
|
||||
|
||||
const QUrl url(m_apiBase + m_pathMonitoringDump);
|
||||
QNetworkRequest request(url);
|
||||
if (!m_accessToken.isEmpty()) {
|
||||
request.setRawHeader("Authorization", ("Bearer " + m_accessToken).toUtf8());
|
||||
}
|
||||
|
||||
qDebug() << "[NetworkService] postMonitoringDump POST" << url.toString();
|
||||
QNetworkReply *reply = m_manager->post(request, multiPart);
|
||||
multiPart->setParent(reply);
|
||||
|
||||
QEventLoop loop;
|
||||
QTimer timeout;
|
||||
timeout.setSingleShot(true);
|
||||
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
timeout.start(120000);
|
||||
loop.exec();
|
||||
|
||||
if (!timeout.isActive()) {
|
||||
qWarning() << "[NetworkService] postMonitoringDump timeout";
|
||||
reply->abort();
|
||||
} else if (reply->error() != QNetworkReply::NoError) {
|
||||
qWarning() << "[NetworkService] postMonitoringDump failed:" << reply->errorString()
|
||||
<< "\n response body:" << reply->readAll();
|
||||
} else {
|
||||
const QByteArray raw = reply->readAll();
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(raw);
|
||||
if (doc.isObject() && !doc.object().value("success").toBool()) {
|
||||
qWarning() << "[NetworkService] postMonitoringDump rejected:" << raw;
|
||||
} else {
|
||||
qDebug() << "[NetworkService] postMonitoringDump OK:" << raw;
|
||||
}
|
||||
}
|
||||
reply->deleteLater();
|
||||
}
|
||||
|
||||
void NetworkService::start() {
|
||||
m_running = true;
|
||||
|
||||
|
||||
@ -60,6 +60,9 @@ public slots:
|
||||
void postMonitoringLog(const QString &type, const QString &event,
|
||||
const QString &message, const QByteArray &image);
|
||||
|
||||
void postMonitoringDump(const QString &text, const QByteArray &archive,
|
||||
const QByteArray &image);
|
||||
|
||||
void start();
|
||||
void stop();
|
||||
void disableAllProfilesOnServer();
|
||||
@ -97,6 +100,7 @@ private:
|
||||
QString m_pathGetTasks;
|
||||
QString m_pathTaskResult;
|
||||
QString m_pathMonitoringLog;
|
||||
QString m_pathMonitoringDump;
|
||||
QString m_pathCurrentProfile;
|
||||
bool m_fetchingTasks = false;
|
||||
|
||||
|
||||
@ -33,6 +33,10 @@
|
||||
#include <QBuffer>
|
||||
#include <QSslSocket>
|
||||
#include <QSslConfiguration>
|
||||
#include <QSettings>
|
||||
#include <QProgressDialog>
|
||||
#include <QTimer>
|
||||
#include <QDebug>
|
||||
#include <QTimer>
|
||||
#include <QRegularExpression>
|
||||
#include <QDateTime>
|
||||
@ -390,7 +394,7 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
|
||||
"border: none;"
|
||||
"border-radius: 4px;"
|
||||
);
|
||||
connect(codeBtn, &QPushButton::clicked, this, [this]() {
|
||||
connect(codeBtn, &QPushButton::clicked, this, [this, codeBtn]() {
|
||||
if (m_device.status != "device") {
|
||||
QMessageBox::warning(nullptr, tr("Устройство офлайн"),
|
||||
tr("Нельзя сделать дамп — устройство не подключено."));
|
||||
@ -398,123 +402,198 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p
|
||||
}
|
||||
const DeviceInfo device = m_device;
|
||||
const QString adbSerial = device.adbSerial.isEmpty() ? device.id : device.adbSerial;
|
||||
const QByteArray screenshot = AdbUtils::captureScreenshotBytes(adbSerial);
|
||||
// Сырой дамп (без фильтра "useful UI"): нужен любой экран — PIN, сплэш, WebView, меню.
|
||||
const QString xml = AdbUtils::getRawScreenDumpAsXml(adbSerial);
|
||||
if (screenshot.isEmpty() && xml.isEmpty()) {
|
||||
QMessageBox::warning(nullptr, tr("Ошибка"), tr("Не удалось получить дамп экрана"));
|
||||
|
||||
QSettings settings(QCoreApplication::applicationDirPath() + "/config.ini",
|
||||
QSettings::IniFormat);
|
||||
const QString apiBase = settings.value("net/apiBase").toString();
|
||||
const QString path = settings.value("net/pathMonitoringDump",
|
||||
"/monitoring/dump").toString();
|
||||
if (apiBase.isEmpty()) {
|
||||
QMessageBox::warning(nullptr, tr("Ошибка"),
|
||||
tr("В config.ini не задан net/apiBase"));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString botToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8";
|
||||
const QString chatId = "-5177086220";
|
||||
const QString desktopId = SettingsDAO::get("desktop_id");
|
||||
const QString caption = QString("Screen dump\nDevice: %1 (%2)\nDesktop: %3")
|
||||
.arg(device.name, adbSerial, desktopId);
|
||||
codeBtn->setEnabled(false);
|
||||
qDebug() << "[Dump] start for device" << adbSerial;
|
||||
|
||||
auto *manager = new QNetworkAccessManager;
|
||||
auto *progress = new QProgressDialog(tr("Сбор дампа..."), QString(), 0, 0, nullptr);
|
||||
progress->setWindowTitle(tr("Дамп"));
|
||||
progress->setWindowModality(Qt::ApplicationModal);
|
||||
progress->setCancelButton(nullptr);
|
||||
progress->setMinimumDuration(0);
|
||||
progress->setAutoClose(false);
|
||||
progress->setAutoReset(false);
|
||||
progress->setWindowFlags(progress->windowFlags()
|
||||
& ~Qt::WindowCloseButtonHint
|
||||
& ~Qt::WindowContextHelpButtonHint);
|
||||
progress->show();
|
||||
|
||||
struct Tally {
|
||||
int pending = 0;
|
||||
QStringList errors;
|
||||
// ADB-захват блокирует поток на несколько секунд — выносим в воркер,
|
||||
// чтобы прогресс-диалог не замораживался.
|
||||
struct DumpData { QByteArray screenshot; QString xml; };
|
||||
auto *data = new DumpData;
|
||||
auto *worker = QThread::create([data, adbSerial]() {
|
||||
qDebug() << "[Dump] worker: capturing screenshot";
|
||||
data->screenshot = AdbUtils::captureScreenshotBytes(adbSerial);
|
||||
qDebug() << "[Dump] worker: screenshot size=" << data->screenshot.size();
|
||||
qDebug() << "[Dump] worker: capturing xml";
|
||||
data->xml = AdbUtils::getRawScreenDumpAsXml(adbSerial);
|
||||
qDebug() << "[Dump] worker: xml size=" << data->xml.size();
|
||||
});
|
||||
|
||||
// Общий флаг "уже обработано" — чтобы страховочный таймаут не сработал
|
||||
// одновременно с finished. Шарится через shared_ptr, чтобы пережить лямбды.
|
||||
auto handled = std::make_shared<bool>(false);
|
||||
QPointer<QPushButton> btnGuard(codeBtn);
|
||||
QPointer<QProgressDialog> progressGuard(progress);
|
||||
|
||||
auto closeAndReenable = [progressGuard, btnGuard]() {
|
||||
if (progressGuard) {
|
||||
progressGuard->reset();
|
||||
progressGuard->hide();
|
||||
progressGuard->deleteLater();
|
||||
}
|
||||
if (btnGuard) btnGuard->setEnabled(true);
|
||||
};
|
||||
auto *tally = new Tally;
|
||||
|
||||
auto track = [manager, tally](QNetworkReply *reply, const QString &label) {
|
||||
++tally->pending;
|
||||
connect(reply, &QNetworkReply::finished, reply, [reply, manager, tally, label]() {
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
tally->errors << QString("%1: %2").arg(label, reply->errorString());
|
||||
// Страховочный таймаут на весь процесс (ADB + upload). Если что-то висит —
|
||||
// диалог гарантированно закроется, и пользователь получит ошибку.
|
||||
// Важно: владелец таймера и приёмник колбэков — qApp, а НЕ `this`.
|
||||
// DeviceWidget пересоздаётся DeviceScreener-ом при каждом опросе устройств,
|
||||
// и если повесить connect(this, ...), при пересборке списка соединение
|
||||
// тихо рвётся, колбэк не запускается, диалог живёт вечно.
|
||||
auto *safetyTimer = new QTimer;
|
||||
safetyTimer->setSingleShot(true);
|
||||
QObject::connect(safetyTimer, &QTimer::timeout, qApp,
|
||||
[handled, closeAndReenable, worker, safetyTimer]() {
|
||||
if (*handled) return;
|
||||
*handled = true;
|
||||
qWarning() << "[Dump] timeout — aborting";
|
||||
if (worker && worker->isRunning()) {
|
||||
worker->terminate();
|
||||
worker->wait(2000);
|
||||
}
|
||||
if (worker) worker->deleteLater();
|
||||
safetyTimer->deleteLater();
|
||||
closeAndReenable();
|
||||
QMessageBox::warning(nullptr, QObject::tr("Таймаут"),
|
||||
QObject::tr("Сбор/отправка дампа заняли больше 90 секунд"));
|
||||
});
|
||||
safetyTimer->start(90000);
|
||||
|
||||
QObject::connect(worker, &QThread::finished, qApp,
|
||||
[worker, data, device, adbSerial, apiBase, path,
|
||||
handled, closeAndReenable, safetyTimer, progressGuard]() {
|
||||
qDebug() << "[Dump] worker finished";
|
||||
if (*handled) {
|
||||
delete data;
|
||||
worker->deleteLater();
|
||||
return;
|
||||
}
|
||||
|
||||
const QByteArray screenshot = data->screenshot;
|
||||
const QString xml = data->xml;
|
||||
delete data;
|
||||
worker->deleteLater();
|
||||
|
||||
if (screenshot.isEmpty() && xml.isEmpty()) {
|
||||
*handled = true;
|
||||
safetyTimer->stop();
|
||||
safetyTimer->deleteLater();
|
||||
closeAndReenable();
|
||||
QMessageBox::warning(nullptr, QObject::tr("Ошибка"),
|
||||
QObject::tr("Не удалось получить дамп экрана"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressGuard) progressGuard->setLabelText(QObject::tr("Отправка дампа..."));
|
||||
qDebug() << "[Dump] uploading: screenshot=" << screenshot.size()
|
||||
<< "xml=" << xml.size();
|
||||
|
||||
const QString desktopId = SettingsDAO::get("desktop_id");
|
||||
const QString text = QString("Screen dump\nDevice: %1 (%2)\nDesktop: %3")
|
||||
.arg(device.name, adbSerial, desktopId);
|
||||
|
||||
auto *mp = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
|
||||
QHttpPart textPart;
|
||||
textPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
R"(form-data; name="text")");
|
||||
textPart.setBody(text.toUtf8());
|
||||
mp->append(textPart);
|
||||
|
||||
if (!xml.isEmpty()) {
|
||||
QString safeName = device.name;
|
||||
safeName.replace(QRegularExpression(R"([^\w\-.])"), "_");
|
||||
const QString ts = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss");
|
||||
const QString xmlFilename = safeName + "_" + ts + ".xml";
|
||||
|
||||
QHttpPart filePart;
|
||||
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "application/xml");
|
||||
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QString(R"(form-data; name="file"; filename="%1")").arg(xmlFilename).toUtf8());
|
||||
filePart.setBody(xml.toUtf8());
|
||||
mp->append(filePart);
|
||||
}
|
||||
|
||||
if (!screenshot.isEmpty()) {
|
||||
QHttpPart imagePart;
|
||||
imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg");
|
||||
imagePart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
R"(form-data; name="image"; filename="screen.jpg")");
|
||||
imagePart.setBody(screenshot);
|
||||
mp->append(imagePart);
|
||||
}
|
||||
|
||||
const QUrl url(apiBase + path);
|
||||
QNetworkRequest req(url);
|
||||
const QString accessToken = SettingsDAO::get("access_token");
|
||||
if (!accessToken.isEmpty()) {
|
||||
req.setRawHeader("Authorization", ("Bearer " + accessToken).toUtf8());
|
||||
}
|
||||
|
||||
auto *manager = new QNetworkAccessManager;
|
||||
QNetworkReply *reply = manager->post(req, mp);
|
||||
mp->setParent(reply);
|
||||
|
||||
QObject::connect(reply, &QNetworkReply::finished, reply,
|
||||
[reply, manager, closeAndReenable, handled, safetyTimer]() {
|
||||
if (*handled) {
|
||||
reply->deleteLater();
|
||||
manager->deleteLater();
|
||||
return;
|
||||
}
|
||||
*handled = true;
|
||||
safetyTimer->stop();
|
||||
safetyTimer->deleteLater();
|
||||
|
||||
const QByteArray body = reply->readAll();
|
||||
bool ok = reply->error() == QNetworkReply::NoError;
|
||||
QString errorDetail;
|
||||
if (!ok) {
|
||||
errorDetail = reply->errorString();
|
||||
} else {
|
||||
// Telegram возвращает 200 + JSON {"ok":false,...} даже на ошибки уровня API
|
||||
const QByteArray body = reply->readAll();
|
||||
if (!body.contains("\"ok\":true")) {
|
||||
tally->errors << QString("%1: API rejected (%2)")
|
||||
.arg(label, QString::fromUtf8(body.left(200)));
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(body);
|
||||
if (doc.isObject() && !doc.object().value("success").toBool()) {
|
||||
ok = false;
|
||||
errorDetail = QString::fromUtf8(body.left(200));
|
||||
}
|
||||
}
|
||||
qDebug() << "[Dump] reply finished: ok=" << ok << "detail=" << errorDetail;
|
||||
reply->deleteLater();
|
||||
if (--tally->pending == 0) {
|
||||
if (tally->errors.isEmpty()) {
|
||||
QMessageBox::information(nullptr, QObject::tr("Готово"),
|
||||
QObject::tr("Дамп отправлен в Telegram"));
|
||||
} else {
|
||||
QMessageBox::warning(nullptr, QObject::tr("Ошибка отправки"),
|
||||
QObject::tr("Не удалось отправить часть данных:\n%1")
|
||||
.arg(tally->errors.join("\n")));
|
||||
}
|
||||
delete tally;
|
||||
manager->deleteLater();
|
||||
manager->deleteLater();
|
||||
closeAndReenable();
|
||||
if (ok) {
|
||||
QMessageBox::information(nullptr, QObject::tr("Готово"),
|
||||
QObject::tr("Дамп отправлен"));
|
||||
} else {
|
||||
QMessageBox::warning(nullptr, QObject::tr("Ошибка отправки"),
|
||||
QObject::tr("Не удалось отправить дамп:\n%1").arg(errorDetail));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 1. Отправляем скриншот через sendPhoto
|
||||
if (!screenshot.isEmpty()) {
|
||||
auto *mp = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
QHttpPart chatPart;
|
||||
chatPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
R"(form-data; name="chat_id")");
|
||||
chatPart.setBody(chatId.toUtf8());
|
||||
mp->append(chatPart);
|
||||
|
||||
QHttpPart captionPart;
|
||||
captionPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
R"(form-data; name="caption")");
|
||||
captionPart.setBody(caption.toUtf8());
|
||||
mp->append(captionPart);
|
||||
|
||||
QHttpPart photoPart;
|
||||
photoPart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg");
|
||||
photoPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
R"(form-data; name="photo"; filename="screen.jpg")");
|
||||
photoPart.setBody(screenshot);
|
||||
mp->append(photoPart);
|
||||
|
||||
const QUrl url("https://api.telegram.org/bot" + botToken + "/sendPhoto");
|
||||
QNetworkRequest req(url);
|
||||
req.setSslConfiguration(QSslConfiguration::defaultConfiguration());
|
||||
QNetworkReply *reply = manager->post(req, mp);
|
||||
mp->setParent(reply);
|
||||
track(reply, "sendPhoto");
|
||||
}
|
||||
|
||||
// 2. Отправляем XML-дамп как документ
|
||||
if (!xml.isEmpty()) {
|
||||
QString safeName = device.name;
|
||||
safeName.replace(QRegularExpression(R"([^\w\-.])"), "_");
|
||||
const QString ts = QDateTime::currentDateTime().toString("yyyy-MM-dd_HH-mm-ss");
|
||||
const QString xmlFilename = safeName + "_" + ts + ".xml";
|
||||
|
||||
auto *mp = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
QHttpPart chatPart;
|
||||
chatPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
R"(form-data; name="chat_id")");
|
||||
chatPart.setBody(chatId.toUtf8());
|
||||
mp->append(chatPart);
|
||||
|
||||
QHttpPart docPart;
|
||||
docPart.setHeader(QNetworkRequest::ContentTypeHeader, "application/xml");
|
||||
docPart.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QString(R"(form-data; name="document"; filename="%1")").arg(xmlFilename).toUtf8());
|
||||
docPart.setBody(xml.toUtf8());
|
||||
mp->append(docPart);
|
||||
|
||||
const QUrl url("https://api.telegram.org/bot" + botToken + "/sendDocument");
|
||||
QNetworkRequest req(url);
|
||||
req.setSslConfiguration(QSslConfiguration::defaultConfiguration());
|
||||
QNetworkReply *reply = manager->post(req, mp);
|
||||
mp->setParent(reply);
|
||||
track(reply, "sendDocument(xml)");
|
||||
}
|
||||
|
||||
if (tally->pending == 0) {
|
||||
// Ничего не запустили — освободим ресурсы
|
||||
delete tally;
|
||||
manager->deleteLater();
|
||||
QMessageBox::warning(nullptr, tr("Ошибка"),
|
||||
tr("Нет данных для отправки"));
|
||||
}
|
||||
});
|
||||
worker->start();
|
||||
});
|
||||
|
||||
topRightLayout->addWidget(settingsBtn);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user