From 11e8f00e42016cb6175864466f866d4c1206602c Mon Sep 17 00:00:00 2001 From: slava Date: Thu, 30 Apr 2026 19:17:17 +0700 Subject: [PATCH] =?UTF-8?q?Add=20OCR=20utilities=20and=20backend=20integra?= =?UTF-8?q?tion=20for=20enhanced=20screen=20dump=20analysis:=20-=20Introdu?= =?UTF-8?q?ce=20`OcrUtils`=20with=20fuzzy-matching=20logic=20for=20text=20?= =?UTF-8?q?recognition=20on=20screenshots.=20-=20Implement=20dual-pass=20O?= =?UTF-8?q?CR=20to=20handle=20light-on-dark=20and=20dark-on-light=20text?= =?UTF-8?q?=20cases.=20-=20Replace=20hardcoded=20"=D0=9F=D0=BE=D0=B4=D1=82?= =?UTF-8?q?=D0=B2=D0=B5=D1=80=D0=B6=D0=B4=D0=B0=D1=8E"=20button=20tap=20lo?= =?UTF-8?q?gic=20with=20OCR-based=20detection=20and=20fallback=20geometry.?= =?UTF-8?q?=20-=20Update=20`TaskDumpRecorder`=20to=20utilize=20`NetworkSer?= =?UTF-8?q?vice`=20for=20uploading=20screen=20dumps,=20images,=20and=20met?= =?UTF-8?q?adata=20to=20the=20backend.=20-=20Add=20support=20for=20`/monit?= =?UTF-8?q?oring/dump`=20endpoint=20in=20`NetworkService`.=20-=20Enhance?= =?UTF-8?q?=20UI=20functionality=20with=20new=20error=20handling=20and=20p?= =?UTF-8?q?rogress=20feedback=20during=20dump=20creation=20and=20upload.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/dump/TaskDumpRecorder.cpp | 111 ++++++---- android/dump/TaskDumpRecorder.h | 9 +- android/dushanbe/PayByCardScript.cpp | 21 +- android/dushanbe/PayByPhoneScript.cpp | 20 +- android/ocr/OcrUtils.cpp | 153 ++++++++++++++ android/ocr/OcrUtils.h | 22 ++ main.cpp | 2 + services/net/NetworkService.cpp | 70 +++++++ services/net/NetworkService.h | 4 + views/device/DeviceWidget.cpp | 289 ++++++++++++++++---------- 10 files changed, 547 insertions(+), 154 deletions(-) create mode 100644 android/ocr/OcrUtils.cpp create mode 100644 android/ocr/OcrUtils.h diff --git a/android/dump/TaskDumpRecorder.cpp b/android/dump/TaskDumpRecorder.cpp index 9c61e4a..0b43688 100644 --- a/android/dump/TaskDumpRecorder.cpp +++ b/android/dump/TaskDumpRecorder.cpp @@ -4,14 +4,18 @@ #include #include #include +#include +#include #include #include +#include #include #include #include #include #include "adb/AdbUtils.h" +#include "net/NetworkService.h" namespace { @@ -20,11 +24,16 @@ QThreadStorage &tlsRecorder() { return tls; } -constexpr const char *kBotToken = "8256716069:AAFgZRB_0Y6KTpqFQmCxIlZiWdY5m2dR2D8"; -constexpr const char *kChatId = "-5177086220"; +QPointer &networkServiceRef() { + static QPointer 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(); diff --git a/android/dump/TaskDumpRecorder.h b/android/dump/TaskDumpRecorder.h index 673d03c..9c8bd47 100644 --- a/android/dump/TaskDumpRecorder.h +++ b/android/dump/TaskDumpRecorder.h @@ -2,7 +2,9 @@ #include -// Мета-поля задачи. Пустые/нулевые значения не попадают в 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(); diff --git a/android/dushanbe/PayByCardScript.cpp b/android/dushanbe/PayByCardScript.cpp index 7feceeb..5182bf6 100644 --- a/android/dushanbe/PayByCardScript.cpp +++ b/android/dushanbe/PayByCardScript.cpp @@ -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; diff --git a/android/dushanbe/PayByPhoneScript.cpp b/android/dushanbe/PayByPhoneScript.cpp index c725f17..b5dba4a 100644 --- a/android/dushanbe/PayByPhoneScript.cpp +++ b/android/dushanbe/PayByPhoneScript.cpp @@ -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; diff --git a/android/ocr/OcrUtils.cpp b/android/ocr/OcrUtils.cpp new file mode 100644 index 0000000..4f606b3 --- /dev/null +++ b/android/ocr/OcrUtils.cpp @@ -0,0 +1,153 @@ +#include "OcrUtils.h" + +#include +#include +#include + +#include +#include + +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 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(png.constData()), + static_cast(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 \ No newline at end of file diff --git a/android/ocr/OcrUtils.h b/android/ocr/OcrUtils.h new file mode 100644 index 0000000..97a57d9 --- /dev/null +++ b/android/ocr/OcrUtils.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include +#include + +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 \ No newline at end of file diff --git a/main.cpp b/main.cpp index db14143..04df10f 100644 --- a/main.cpp +++ b/main.cpp @@ -50,6 +50,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) { #include #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); diff --git a/services/net/NetworkService.cpp b/services/net/NetworkService.cpp index 766aa58..00069ee 100644 --- a/services/net/NetworkService.cpp +++ b/services/net/NetworkService.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -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; diff --git a/services/net/NetworkService.h b/services/net/NetworkService.h index 74f7da2..fdfe072 100644 --- a/services/net/NetworkService.h +++ b/services/net/NetworkService.h @@ -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; diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index d018688..dc8f52c 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -33,6 +33,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -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(false); + QPointer btnGuard(codeBtn); + QPointer 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);