1
0
forked from BRT/arc

Add parallel ADB scanning with QtConcurrent, optimize device sync logic, and replace blocking API calls with fire-and-forget dispatch for improved performance.

This commit is contained in:
slava 2026-06-01 14:54:17 +05:00
parent 29ae105c64
commit e08514ea2e
3 changed files with 292 additions and 172 deletions

View File

@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS
Widgets Widgets
Sql Sql
Xml Xml
Concurrent
) )
file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS
@ -156,6 +157,7 @@ if (WIN32)
Qt6::Xml Qt6::Xml
Qt6::Sql Qt6::Sql
Qt6::Network Qt6::Network
Qt6::Concurrent
Tesseract::libtesseract Tesseract::libtesseract
) )
@ -276,6 +278,7 @@ elseif (APPLE)
Qt6::Sql Qt6::Sql
Qt6::Xml Qt6::Xml
Qt6::Network Qt6::Network
Qt6::Concurrent
tesseract tesseract
leptonica leptonica
) )

View File

@ -7,6 +7,8 @@
#include <QBuffer> #include <QBuffer>
#include <qeventloop.h> #include <qeventloop.h>
#include <QThread> #include <QThread>
#include <QThreadPool>
#include <QtConcurrent>
#include <QTimer> #include <QTimer>
#include <QImage> #include <QImage>
#include <QCryptographicHash> #include <QCryptographicHash>
@ -152,35 +154,6 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body)
return result; return result;
} }
void DeviceScreener::apiPatch(const QString &path, const QJsonObject &body) {
if (!m_manager) m_manager = new QNetworkAccessManager(this);
QNetworkRequest request(QUrl(m_apiBase + path));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Authorization",
("Bearer " + SettingsDAO::get("access_token")).toUtf8());
QNetworkReply *reply = m_manager->sendCustomRequest(request, "PATCH", QJsonDocument(body).toJson());
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); });
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
if (!timeout.isActive()) {
qCritical().noquote() << "DeviceScreener apiPatch timeout:"
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort();
} else if (reply->error() != QNetworkReply::NoError) {
qCritical().noquote() << "DeviceScreener apiPatch error:"
<< NetworkErrorUtils::describeNetError(reply, path);
}
reply->deleteLater();
}
QString DeviceScreener::createDeviceOnApi(const DeviceInfo &device) { QString DeviceScreener::createDeviceOnApi(const DeviceInfo &device) {
const QString desktopId = SettingsDAO::get("desktop_id"); const QString desktopId = SettingsDAO::get("desktop_id");
if (desktopId.isEmpty()) { if (desktopId.isEmpty()) {
@ -204,51 +177,6 @@ QString DeviceScreener::createDeviceOnApi(const DeviceInfo &device) {
return {}; return {};
} }
void DeviceScreener::postScreenshotToApi(const QString &apiId, const QByteArray &image) {
if (!m_manager) m_manager = new QNetworkAccessManager(this);
const QString path = "/api/v1/device/add_screenshot/" + apiId;
QNetworkRequest request(QUrl(m_apiBase + path));
request.setRawHeader("Authorization",
("Bearer " + SettingsDAO::get("access_token")).toUtf8());
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg");
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"file\"; filename=\"screenshot.jpg\""));
filePart.setBody(image);
multiPart->append(filePart);
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(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); });
connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
if (!timeout.isActive()) {
qWarning().noquote() << "postScreenshotToApi timeout:" << apiId
<< NetworkErrorUtils::describeClientTimeout(reply, path);
reply->abort();
} else if (reply->error() != QNetworkReply::NoError) {
qWarning().noquote() << "postScreenshotToApi error:" << apiId
<< NetworkErrorUtils::describeNetError(reply, path);
} else {
const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
if (doc.isObject() && doc.object()["success"].toBool()) {
// ok
} else {
qWarning() << "postScreenshotToApi failed for device" << apiId << ":" << doc.toJson(QJsonDocument::Compact);
}
}
reply->deleteLater();
}
void DeviceScreener::dispatchScreenshotToMonitoring(const QString &desktopId, void DeviceScreener::dispatchScreenshotToMonitoring(const QString &desktopId,
const QString &deviceId, const QString &deviceId,
const QString &name, const QString &name,
@ -271,8 +199,76 @@ void DeviceScreener::dispatchScreenshotToMonitoring(const QString &desktopId,
thread->start(); thread->start();
} }
void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) { void DeviceScreener::dispatchDeviceSyncToApi(const QString &apiBase,
// Находим первый активный bank_profile для этого устройства const QString &accessToken,
const QString &apiId,
const QJsonObject &patchBody,
const QByteArray &image) {
if (apiId.isEmpty()) return;
// Отдельный поток + локальный QNetworkAccessManager, чтобы 30-секундный таймаут
// PATCH/POST не блокировал цикл опроса (паттерн dispatchScreenshotToMonitoring).
auto *thread = new QThread;
QObject::connect(thread, &QThread::started, [=]() {
QNetworkAccessManager manager; // живёт в этом потоке
const QByteArray auth = ("Bearer " + accessToken).toUtf8();
// Локальный цикл ожидания одного reply с таймаутом 30с.
auto waitReply = [](QNetworkReply *reply) {
QEventLoop loop;
QTimer timeout;
timeout.setSingleShot(true);
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
QObject::connect(reply, &QNetworkReply::errorOccurred, [&loop](QNetworkReply::NetworkError) { loop.quit(); });
QObject::connect(&timeout, &QTimer::timeout, &loop, &QEventLoop::quit);
timeout.start(30000);
loop.exec();
if (!timeout.isActive()) reply->abort();
};
// PATCH статуса устройства.
{
QNetworkRequest request(QUrl(apiBase + "/api/v1/device/" + apiId));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
request.setRawHeader("Authorization", auth);
QNetworkReply *reply = manager.sendCustomRequest(request, "PATCH", QJsonDocument(patchBody).toJson());
waitReply(reply);
if (reply->error() != QNetworkReply::NoError) {
qWarning().noquote() << "dispatchDeviceSyncToApi PATCH error:" << apiId << reply->errorString();
}
reply->deleteLater();
}
// POST скриншота (если есть).
if (!image.isEmpty()) {
auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType);
QHttpPart filePart;
filePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg");
filePart.setHeader(QNetworkRequest::ContentDispositionHeader,
QStringLiteral("form-data; name=\"file\"; filename=\"screenshot.jpg\""));
filePart.setBody(image);
multiPart->append(filePart);
QNetworkRequest request(QUrl(apiBase + "/api/v1/device/add_screenshot/" + apiId));
request.setRawHeader("Authorization", auth);
QNetworkReply *reply = manager.post(request, multiPart);
multiPart->setParent(reply);
waitReply(reply);
if (reply->error() != QNetworkReply::NoError) {
qWarning().noquote() << "dispatchDeviceSyncToApi screenshot error:" << apiId << reply->errorString();
}
reply->deleteLater();
}
thread->quit();
});
QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater);
thread->start();
}
void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device, const QByteArray &image) {
// Находим первый активный bank_profile для этого устройства (БД-запрос — должен
// выполняться на главном потоке, поэтому строим тело здесь).
QJsonValue bankProfileId = QJsonValue::Null; QJsonValue bankProfileId = QJsonValue::Null;
const QList<BankProfileInfo> apps = BankProfileDAO::getAllApplicationsByDeviceId(device.id); const QList<BankProfileInfo> apps = BankProfileDAO::getAllApplicationsByDeviceId(device.id);
for (const BankProfileInfo &app : apps) { for (const BankProfileInfo &app : apps) {
@ -287,7 +283,10 @@ void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) {
body["bank_profile_id"] = bankProfileId; body["bank_profile_id"] = bankProfileId;
body["battery"] = device.battery; body["battery"] = device.battery;
body["update_time"] = QDateTime::currentSecsSinceEpoch(); body["update_time"] = QDateTime::currentSecsSinceEpoch();
apiPatch("/api/v1/device/" + device.apiId, body);
// Сетевую часть — fire-and-forget, чтобы таймаут не тормозил цикл опроса.
dispatchDeviceSyncToApi(m_apiBase, SettingsDAO::get("access_token"),
device.apiId, body, image);
} }
QJsonValue DeviceScreener::apiGet(const QString &path) { QJsonValue DeviceScreener::apiGet(const QString &path) {
@ -329,78 +328,110 @@ QJsonValue DeviceScreener::apiGet(const QString &path) {
void DeviceScreener::start() { void DeviceScreener::start() {
// Пул для параллельного adb-опроса (фаза A). Лимит kMaxParallelScans, чтобы
// одновременные screencap'ы не насыщали USB-шину.
QThreadPool scanPool;
scanPool.setMaxThreadCount(kMaxParallelScans);
while (m_running) { while (m_running) {
QList<QPair<QString, QString> > devices = readAdbDevices(); QList<QPair<QString, QString> > devices = readAdbDevices();
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
const qint64 nowSec = QDateTime::currentSecsSinceEpoch(); const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
// --- Фаза A: чистый adb-I/O параллельно по всем "device".
// Устройства не в статусе "device" (offline/unauthorized) игнорируем — они
// и раньше не попадали в liveSet и не меняли состояние, лишь уходили в
// офлайн по дебаунсу. Снимок кэша берём на главном потоке (read-only вход
// в потоки пула — без гонок).
QStringList deviceSerials;
for (const auto &[adbSerial, status]: devices) { for (const auto &[adbSerial, status]: devices) {
DeviceInfo device; if (status == "device") deviceSerials << adbSerial;
}
if (status == "device") { const QMap<QString, DeviceInfo> cacheSnapshot = m_staticInfoCache;
device = readDeviceInfo(adbSerial); // Какие устройства пора перечитать на список приложений (троттл).
QSet<QString> appsDue;
// Проверяем: есть ли уже устройство с таким id в БД (с apiId) for (const QString &serial : deviceSerials) {
const DeviceInfo existingById = DeviceDAO::getDeviceById(device.id); if (nowSec - m_lastAppsCheck.value(serial, 0) >= kAppsCheckIntervalSecs) {
if (!existingById.id.isEmpty() && !existingById.apiId.isEmpty()) { appsDue.insert(serial);
device.apiId = existingById.apiId;
} else {
// Маппинг с восстановленным устройством — только по name (точное совпадение)
const DeviceInfo restored = DeviceDAO::findByName(device.name);
qDebug() << "[DeviceScreener] findByName(" << device.name << ") →"
<< "id:" << restored.id << "apiId:" << restored.apiId
<< "match:" << (!restored.id.isEmpty() && restored.id != device.id && !restored.apiId.isEmpty());
if (!restored.id.isEmpty() && restored.id != device.id && !restored.apiId.isEmpty()) {
device.apiId = restored.apiId;
// Сначала создаём новое устройство (FK target), затем мигрируем, затем удаляем старое
DeviceDAO::upsertDevice(device);
const bool bpOk = BankProfileDAO::migrateDeviceId(restored.id, device.id);
const bool matOk = MaterialDAO::migrateDeviceId(restored.id, device.id);
qDebug() << "[DeviceScreener] migration result: bp=" << bpOk << "mat=" << matOk;
DeviceDAO::deleteDevice(restored.id);
qDebug() << "[DeviceScreener] matched restored device:" << restored.id << "->" << device.id
<< "apiId:" << device.apiId;
}
}
liveSet.insert(device.id);
device.image = takeScreenshot(adbSerial);
if (!device.image.isEmpty()) {
if (!DeviceDAO::updateImage(device.id, device.image)) {
qDebug() << "Cant update screenshot";
}
}
} else {
device.id = adbSerial;
} }
device.status = status; }
device.updateTime = QDateTime::currentDateTime(); const QList<ScanResult> scans = QtConcurrent::blockingMapped<QList<ScanResult> >(
if (status == "device") { &scanPool, deviceSerials,
if (!DeviceDAO::upsertDevice(device)) { [&cacheSnapshot, &appsDue](const QString &serial) {
qDebug() << "Cant update device"; return scanDeviceIo(serial, cacheSnapshot.value(serial), appsDue.contains(serial));
} });
// Проверяем установленные приложения каждый цикл // --- Фаза B: всё, что трогает БД/члены класса — последовательно в этом потоке.
QSet<QString> apps = AdbUtils::getListApps(adbSerial); for (const ScanResult &scan : scans) {
if (!BankProfileDAO::checkInstalledApps(device.id, apps)) { // android_id не прочитался — пропускаем устройство в этом цикле.
if (!scan.identified) continue;
DeviceInfo device = scan.info;
device.status = "device";
device.updateTime = QDateTime::currentDateTime();
// Обновляем кэш статики/battery (главный поток — безопасно).
m_staticInfoCache[scan.adbSerial] = scan.info;
// Проверяем: есть ли уже устройство с таким id в БД (с apiId)
const DeviceInfo existingById = DeviceDAO::getDeviceById(device.id);
if (!existingById.id.isEmpty() && !existingById.apiId.isEmpty()) {
device.apiId = existingById.apiId;
} else {
// Маппинг с восстановленным устройством — только по name (точное совпадение)
const DeviceInfo restored = DeviceDAO::findByName(device.name);
qDebug() << "[DeviceScreener] findByName(" << device.name << ") →"
<< "id:" << restored.id << "apiId:" << restored.apiId
<< "match:" << (!restored.id.isEmpty() && restored.id != device.id && !restored.apiId.isEmpty());
if (!restored.id.isEmpty() && restored.id != device.id && !restored.apiId.isEmpty()) {
device.apiId = restored.apiId;
// Сначала создаём новое устройство (FK target), затем мигрируем, затем удаляем старое
DeviceDAO::upsertDevice(device);
const bool bpOk = BankProfileDAO::migrateDeviceId(restored.id, device.id);
const bool matOk = MaterialDAO::migrateDeviceId(restored.id, device.id);
qDebug() << "[DeviceScreener] migration result: bp=" << bpOk << "mat=" << matOk;
DeviceDAO::deleteDevice(restored.id);
qDebug() << "[DeviceScreener] matched restored device:" << restored.id << "->" << device.id
<< "apiId:" << device.apiId;
}
}
liveSet.insert(device.id);
device.image = scan.image;
if (!device.image.isEmpty()) {
if (!DeviceDAO::updateImage(device.id, device.image)) {
qDebug() << "Cant update screenshot";
}
}
if (!DeviceDAO::upsertDevice(device)) {
qDebug() << "Cant update device";
}
// Проверяем установленные приложения по троттлу (список прочитан в фазе A).
// Пустой список = сбой adb — не двигаем метку, чтобы перечитать на след.
// цикле и не пометить все приложения как отсутствующие.
if (scan.appsRead && !scan.apps.isEmpty()) {
if (!BankProfileDAO::checkInstalledApps(device.id, scan.apps)) {
qWarning() << "Cant check installed apps"; qWarning() << "Cant check installed apps";
} }
m_lastAppsCheck[scan.adbSerial] = nowSec;
}
// Обновляем версии установленных банков (не чаще раза в 6 часов на профиль) // Обновляем версии установленных банков (не чаще раза в 6 часов на профиль)
constexpr qint64 kAppVersionRefreshSecs = 6 * 60 * 60; constexpr qint64 kAppVersionRefreshSecs = 6 * 60 * 60;
const QDateTime nowUtc = QDateTime::currentDateTimeUtc(); const QDateTime nowUtc = QDateTime::currentDateTimeUtc();
for (const BankProfileInfo &profile : BankProfileDAO::getApplicationsByDeviceId(device.id)) { for (const BankProfileInfo &profile : BankProfileDAO::getApplicationsByDeviceId(device.id)) {
if (profile.package.isEmpty()) continue; if (profile.package.isEmpty()) continue;
const bool stale = !profile.appVersionCheckedAt.isValid() const bool stale = !profile.appVersionCheckedAt.isValid()
|| profile.appVersionCheckedAt.secsTo(nowUtc) >= kAppVersionRefreshSecs; || profile.appVersionCheckedAt.secsTo(nowUtc) >= kAppVersionRefreshSecs;
if (!stale) continue; if (!stale) continue;
const QString ver = AdbUtils::getAppVersion(adbSerial, profile.package); const QString ver = AdbUtils::getAppVersion(scan.adbSerial, profile.package);
if (ver.isEmpty()) continue; // не затираем известную версию пустотой if (ver.isEmpty()) continue; // не затираем известную версию пустотой
if (!BankProfileDAO::updateAppVersion(profile.id, ver)) { if (!BankProfileDAO::updateAppVersion(profile.id, ver)) {
qWarning() << "Cant update app_version for" << profile.code << profile.package; qWarning() << "Cant update app_version for" << profile.code << profile.package;
}
} }
} }
@ -415,15 +446,13 @@ void DeviceScreener::start() {
} }
qDebug() << "Device registered on API:" << device.id << "->" << apiId; qDebug() << "Device registered on API:" << device.id << "->" << apiId;
} }
} else if (status == "device") { } else {
const qint64 now = QDateTime::currentSecsSinceEpoch(); const qint64 now = QDateTime::currentSecsSinceEpoch();
// Основной API обновляем не чаще раза в 60 секунд. // Основной API обновляем не чаще раза в 60 секунд.
if (now - m_lastApiUpdate.value(device.id, 0) >= 60) { if (now - m_lastApiUpdate.value(device.id, 0) >= 60) {
device.apiId = saved.apiId; device.apiId = saved.apiId;
updateDeviceOnApi(device); // PATCH статуса + POST скриншота уходят fire-and-forget внутри.
if (!device.image.isEmpty()) { updateDeviceOnApi(device, device.image);
postScreenshotToApi(saved.apiId, device.image);
}
m_lastApiUpdate[device.id] = now; m_lastApiUpdate[device.id] = now;
} }
@ -473,6 +502,15 @@ void DeviceScreener::start() {
for (const QString &id : goneOffline) { for (const QString &id : goneOffline) {
m_onlineDevices.remove(id); m_onlineDevices.remove(id);
m_offlineSince.remove(id); m_offlineSince.remove(id);
// Сбрасываем кэш статики для этого устройства: при переподключении по тому
// же adbSerial может оказаться другой аппарат — перечитаем заново.
for (auto it = m_staticInfoCache.begin(); it != m_staticInfoCache.end();) {
if (it.value().id == id) {
it = m_staticInfoCache.erase(it);
} else {
++it;
}
}
qDebug() << "[DeviceScreener] device went OFFLINE (debounced):" << id; qDebug() << "[DeviceScreener] device went OFFLINE (debounced):" << id;
// Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка) // Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка)
const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id);
@ -584,45 +622,83 @@ QList<QPair<QString, QString> > DeviceScreener::readAdbDevices() {
static const QRegularExpression screenRegex(R"(Physical size: (\d+)x(\d+))"); static const QRegularExpression screenRegex(R"(Physical size: (\d+)x(\d+))");
static const QRegularExpression batteryRegex(R"(level:\s*(\d+))"); static const QRegularExpression batteryRegex(R"(level:\s*(\d+))");
DeviceInfo DeviceScreener::readDeviceInfo(const QString &adbSerial) { DeviceScreener::ScanResult DeviceScreener::scanDeviceIo(const QString &adbSerial,
DeviceInfo info; const DeviceInfo &cachedStatic,
info.screenWidth = 0; bool readApps) {
info.screenHeight = 0; ScanResult result;
info.battery = 0; result.adbSerial = adbSerial;
auto runAdbCommand = [&](const QStringList &args) -> QString { // ok=false означает, что процесс не завершился за таймаут (зависшая команда):
// результат считаем «нет данных», а не валидной пустой строкой, чтобы вызывающий
// код не затирал известные значения.
auto runAdbCommand = [&](const QStringList &args, bool *ok = nullptr) -> QString {
QProcess process; QProcess process;
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath()); process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
process.start(AdbUtils::adbPath(), QStringList{"-s", adbSerial} + args); process.start(AdbUtils::adbPath(), QStringList{"-s", adbSerial} + args);
process.waitForFinished(); if (!process.waitForFinished(kAdbCommandTimeoutMs)) {
qWarning() << "[DeviceScreener] adb command timeout for" << adbSerial << args;
process.kill();
process.waitForFinished(1000);
if (ok) *ok = false;
return {};
}
if (ok) *ok = true;
return QString::fromUtf8(process.readAllStandardOutput()).trimmed(); return QString::fromUtf8(process.readAllStandardOutput()).trimmed();
}; };
const QString androidId = runAdbCommand({"shell", "settings", "get", "secure", "android_id"}); // Статика (id/name/android/размер экрана) не меняется за сессию. Если передан
const QString model = runAdbCommand({"shell", "getprop", "ro.product.model"}); // непустой кэш — переиспользуем его и не дёргаем adb. Иначе читаем с нуля.
DeviceInfo info = cachedStatic;
if (info.id.isEmpty()) {
bool idOk = false;
bool modelOk = false;
const QString androidId = runAdbCommand({"shell", "settings", "get", "secure", "android_id"}, &idOk);
const QString model = runAdbCommand({"shell", "getprop", "ro.product.model"}, &modelOk);
info.id = androidId.isEmpty() ? adbSerial : androidId; // Идентичность не прочиталась (таймаут/пусто) — НЕ подменяем id на adbSerial.
info.adbSerial = adbSerial; // Возвращаем identified=false; вызывающий код пропустит устройство в этом
info.name = model + "-" + info.id; // цикле и повторит на следующем.
info.android = runAdbCommand({"shell", "getprop", "ro.build.version.release"}); if (!idOk || androidId.isEmpty()) {
qWarning() << "[DeviceScreener] device not identified yet (android_id unavailable):" << adbSerial;
info.adbSerial = adbSerial;
info.updateTime = QDateTime::currentDateTime();
result.info = info;
result.identified = false;
return result;
}
// Размер экрана info.id = androidId;
const QString screenOutput = runAdbCommand({"shell", "wm", "size"}); info.adbSerial = adbSerial;
QRegularExpressionMatch match = screenRegex.match(screenOutput); info.name = (modelOk && !model.isEmpty() ? model : QStringLiteral("device")) + "-" + info.id;
if (match.hasMatch()) { info.android = runAdbCommand({"shell", "getprop", "ro.build.version.release"});
info.screenWidth = match.captured(1).toInt();
info.screenHeight = match.captured(2).toInt(); const QString screenOutput = runAdbCommand({"shell", "wm", "size"});
if (QRegularExpressionMatch match = screenRegex.match(screenOutput); match.hasMatch()) {
info.screenWidth = match.captured(1).toInt();
info.screenHeight = match.captured(2).toInt();
}
} }
result.identified = true;
// Уровень батареи // Battery — динамическое, читаем каждый цикл. На таймаут/непрочитанное значение
const QString batteryOutput = runAdbCommand({"shell", "dumpsys", "battery"}); // оставляем прошлое (info уже содержит закэшированное), а не пишем 0.
match = batteryRegex.match(batteryOutput); bool batteryOk = false;
if (match.hasMatch()) { const QString batteryOutput = runAdbCommand({"shell", "dumpsys", "battery"}, &batteryOk);
info.battery = match.captured(1).toInt(); if (batteryOk) {
if (QRegularExpressionMatch match = batteryRegex.match(batteryOutput); match.hasMatch()) {
info.battery = match.captured(1).toInt();
}
} }
info.updateTime = QDateTime::currentDateTime(); info.updateTime = QDateTime::currentDateTime();
return info; result.info = info;
// Тяжёлый чистый adb-I/O — скриншот всегда, список приложений по троттлу.
result.image = takeScreenshot(adbSerial);
if (readApps) {
result.apps = AdbUtils::getListApps(adbSerial);
result.appsRead = true;
}
return result;
} }

View File

@ -17,6 +17,16 @@ public:
static constexpr qint64 kOfflineDebounceSecs = 30; static constexpr qint64 kOfflineDebounceSecs = 30;
// Как часто проверять/слать кадр истории экрана на сервер логов (с дедупом). // Как часто проверять/слать кадр истории экрана на сервер логов (с дедупом).
static constexpr qint64 kMonitoringScreenIntervalSecs = 30; static constexpr qint64 kMonitoringScreenIntervalSecs = 30;
// Таймаут одной adb-команды в scanDeviceIo. На порядок выше нормального
// времени (<1с), но ограничивает зависшую команду на флапающем устройстве,
// чтобы один телефон не держал весь цикл опроса.
static constexpr int kAdbCommandTimeoutMs = 10000;
// Сколько устройств опрашивать по adb параллельно (фаза A). Ограничено, чтобы
// не насыщать USB-шину одновременными screencap'ами.
static constexpr int kMaxParallelScans = 6;
// Как часто перечитывать список установленных приложений (getListApps). Список
// меняется редко, поэтому не дёргаем adb каждый цикл.
static constexpr qint64 kAppsCheckIntervalSecs = 60;
public slots: public slots:
void start(); void start();
@ -41,22 +51,43 @@ private:
QMap<QString, qint64> m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса) QMap<QString, qint64> m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса)
QMap<QString, qint64> m_lastMonitoringScreen; // deviceId → unix ts последней проверки кадра для истории экрана QMap<QString, qint64> m_lastMonitoringScreen; // deviceId → unix ts последней проверки кадра для истории экрана
QMap<QString, QByteArray> m_lastSentScreenHash; // deviceId → sha256 последнего кадра, отправленного в историю экрана QMap<QString, QByteArray> m_lastSentScreenHash; // deviceId → sha256 последнего кадра, отправленного в историю экрана
// Кэш неизменных свойств устройства (id/name/android/размер экрана) по adbSerial.
// Эти поля не меняются за сессию, поэтому читаются по adb один раз, а не каждый
// проход цикла. Каждый цикл обновляется только battery. Инвалидируется при уходе
// устройства в OFFLINE (на случай переподключения другого аппарата по тому же serial).
QMap<QString, DeviceInfo> m_staticInfoCache;
QMap<QString, qint64> m_lastAppsCheck; // adbSerial → unix ts последнего getListApps
// Результат чистого adb-опроса одного устройства (фаза A, выполняется в пуле
// потоков). Не содержит ничего, что требует БД/членов класса.
struct ScanResult {
QString adbSerial;
DeviceInfo info; // идентичность + battery + размер экрана
bool identified = false; // false → android_id не прочитался, пропустить цикл
QByteArray image; // JPEG скриншот (может быть пустым)
QSet<QString> apps; // установленные пакеты (валидно только при appsRead)
bool appsRead = false; // true → getListApps вызывался в этом цикле
};
static QList<QPair<QString, QString> > readAdbDevices(); static QList<QPair<QString, QString> > readAdbDevices();
static QByteArray takeScreenshot(const QString &deviceId); static QByteArray takeScreenshot(const QString &deviceId);
static DeviceInfo readDeviceInfo(const QString &adbSerial); // Чистый adb-I/O одного устройства: потокобезопасно (не трогает БД и члены).
// cachedStatic — снимок ранее прочитанной статики (пустой id → читаем с нуля).
// readApps — перечитывать ли список установленных приложений в этом цикле.
static ScanResult scanDeviceIo(const QString &adbSerial, const DeviceInfo &cachedStatic, bool readApps);
void postDevicesData(const QList<DeviceInfo> &devices); void postDevicesData(const QList<DeviceInfo> &devices);
QJsonValue apiGet(const QString &path); QJsonValue apiGet(const QString &path);
QJsonValue apiPost(const QString &path, const QJsonObject &body); QJsonValue apiPost(const QString &path, const QJsonObject &body);
void apiPatch(const QString &path, const QJsonObject &body);
QString createDeviceOnApi(const DeviceInfo &device); QString createDeviceOnApi(const DeviceInfo &device);
void updateDeviceOnApi(const DeviceInfo &device); // Строит тело PATCH (с поиском bank_profile_id по БД — вызывать на главном
void postScreenshotToApi(const QString &apiId, const QByteArray &image); // потоке) и отправляет статус + опциональный скриншот fire-and-forget через
// dispatchDeviceSyncToApi, чтобы сетевой таймаут не тормозил цикл опроса.
void updateDeviceOnApi(const DeviceInfo &device, const QByteArray &image = {});
// Fire-and-forget отправка кадра истории экрана на сервер логов (monitoringBase). // Fire-and-forget отправка кадра истории экрана на сервер логов (monitoringBase).
// Создаёт временный NetworkService в отдельном потоке (паттерн LogArchiveSender). // Создаёт временный NetworkService в отдельном потоке (паттерн LogArchiveSender).
@ -64,4 +95,14 @@ private:
const QString &deviceId, const QString &deviceId,
const QString &name, const QString &name,
const QByteArray &jpeg); const QByteArray &jpeg);
// Fire-and-forget синхронизация устройства с основным API: PATCH статуса и
// (если image непустой) POST скриншота. Поднимает свой поток + QNetworkAccessManager,
// чтобы 30-секундный таймаут не блокировал цикл опроса. Всё необходимое
// (токен, тело запроса) передаётся по значению — БД здесь не трогается.
static void dispatchDeviceSyncToApi(const QString &apiBase,
const QString &accessToken,
const QString &apiId,
const QJsonObject &patchBody,
const QByteArray &image);
}; };