1
0
forked from BRT/arc

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:
slava 2026-04-30 19:17:17 +07:00
parent 6de3751a60
commit 11e8f00e42
10 changed files with 547 additions and 154 deletions

View File

@ -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();

View File

@ -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();

View File

@ -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;

View File

@ -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
View 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
View 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

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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);