From e08514ea2e77dffcf464db2aba7bd788872d2e46 Mon Sep 17 00:00:00 2001 From: slava Date: Mon, 1 Jun 2026 14:54:17 +0500 Subject: [PATCH] Add parallel ADB scanning with `QtConcurrent`, optimize device sync logic, and replace blocking API calls with fire-and-forget dispatch for improved performance. --- CMakeLists.txt | 3 + services/DeviceScreener.cpp | 412 +++++++++++++++++++++--------------- services/DeviceScreener.h | 49 ++++- 3 files changed, 292 insertions(+), 172 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 9d59db3..de53847 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,6 +58,7 @@ find_package(Qt6 REQUIRED COMPONENTS Widgets Sql Xml + Concurrent ) file(GLOB_RECURSE DB_SOURCES CONFIGURE_DEPENDS @@ -156,6 +157,7 @@ if (WIN32) Qt6::Xml Qt6::Sql Qt6::Network + Qt6::Concurrent Tesseract::libtesseract ) @@ -276,6 +278,7 @@ elseif (APPLE) Qt6::Sql Qt6::Xml Qt6::Network + Qt6::Concurrent tesseract leptonica ) diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index 9723707..b6a12df 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -7,6 +7,8 @@ #include #include #include +#include +#include #include #include #include @@ -152,35 +154,6 @@ QJsonValue DeviceScreener::apiPost(const QString &path, const QJsonObject &body) 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) { const QString desktopId = SettingsDAO::get("desktop_id"); if (desktopId.isEmpty()) { @@ -204,51 +177,6 @@ QString DeviceScreener::createDeviceOnApi(const DeviceInfo &device) { 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, const QString &deviceId, const QString &name, @@ -271,8 +199,76 @@ void DeviceScreener::dispatchScreenshotToMonitoring(const QString &desktopId, thread->start(); } -void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) { - // Находим первый активный bank_profile для этого устройства +void DeviceScreener::dispatchDeviceSyncToApi(const QString &apiBase, + 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; const QList apps = BankProfileDAO::getAllApplicationsByDeviceId(device.id); for (const BankProfileInfo &app : apps) { @@ -287,7 +283,10 @@ void DeviceScreener::updateDeviceOnApi(const DeviceInfo &device) { body["bank_profile_id"] = bankProfileId; body["battery"] = device.battery; 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) { @@ -329,78 +328,110 @@ QJsonValue DeviceScreener::apiGet(const QString &path) { void DeviceScreener::start() { + // Пул для параллельного adb-опроса (фаза A). Лимит kMaxParallelScans, чтобы + // одновременные screencap'ы не насыщали USB-шину. + QThreadPool scanPool; + scanPool.setMaxThreadCount(kMaxParallelScans); + while (m_running) { QList > devices = readAdbDevices(); QSet liveSet; // устройства, видимые как "device" в текущем скане const qint64 nowSec = QDateTime::currentSecsSinceEpoch(); + // --- Фаза A: чистый adb-I/O параллельно по всем "device". + // Устройства не в статусе "device" (offline/unauthorized) игнорируем — они + // и раньше не попадали в liveSet и не меняли состояние, лишь уходили в + // офлайн по дебаунсу. Снимок кэша берём на главном потоке (read-only вход + // в потоки пула — без гонок). + QStringList deviceSerials; for (const auto &[adbSerial, status]: devices) { - DeviceInfo device; - - if (status == "device") { - device = readDeviceInfo(adbSerial); - - // Проверяем: есть ли уже устройство с таким 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 = takeScreenshot(adbSerial); - if (!device.image.isEmpty()) { - if (!DeviceDAO::updateImage(device.id, device.image)) { - qDebug() << "Cant update screenshot"; - } - } - } else { - device.id = adbSerial; + if (status == "device") deviceSerials << adbSerial; + } + const QMap cacheSnapshot = m_staticInfoCache; + // Какие устройства пора перечитать на список приложений (троттл). + QSet appsDue; + for (const QString &serial : deviceSerials) { + if (nowSec - m_lastAppsCheck.value(serial, 0) >= kAppsCheckIntervalSecs) { + appsDue.insert(serial); } - device.status = status; - device.updateTime = QDateTime::currentDateTime(); - if (status == "device") { - if (!DeviceDAO::upsertDevice(device)) { - qDebug() << "Cant update device"; - } + } + const QList scans = QtConcurrent::blockingMapped >( + &scanPool, deviceSerials, + [&cacheSnapshot, &appsDue](const QString &serial) { + return scanDeviceIo(serial, cacheSnapshot.value(serial), appsDue.contains(serial)); + }); - // Проверяем установленные приложения каждый цикл - QSet apps = AdbUtils::getListApps(adbSerial); - if (!BankProfileDAO::checkInstalledApps(device.id, apps)) { + // --- Фаза B: всё, что трогает БД/члены класса — последовательно в этом потоке. + for (const ScanResult &scan : scans) { + // 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"; } + m_lastAppsCheck[scan.adbSerial] = nowSec; + } - // Обновляем версии установленных банков (не чаще раза в 6 часов на профиль) - constexpr qint64 kAppVersionRefreshSecs = 6 * 60 * 60; - const QDateTime nowUtc = QDateTime::currentDateTimeUtc(); - for (const BankProfileInfo &profile : BankProfileDAO::getApplicationsByDeviceId(device.id)) { - if (profile.package.isEmpty()) continue; - const bool stale = !profile.appVersionCheckedAt.isValid() - || profile.appVersionCheckedAt.secsTo(nowUtc) >= kAppVersionRefreshSecs; - if (!stale) continue; + // Обновляем версии установленных банков (не чаще раза в 6 часов на профиль) + constexpr qint64 kAppVersionRefreshSecs = 6 * 60 * 60; + const QDateTime nowUtc = QDateTime::currentDateTimeUtc(); + for (const BankProfileInfo &profile : BankProfileDAO::getApplicationsByDeviceId(device.id)) { + if (profile.package.isEmpty()) continue; + const bool stale = !profile.appVersionCheckedAt.isValid() + || profile.appVersionCheckedAt.secsTo(nowUtc) >= kAppVersionRefreshSecs; + if (!stale) continue; - const QString ver = AdbUtils::getAppVersion(adbSerial, profile.package); - if (ver.isEmpty()) continue; // не затираем известную версию пустотой - if (!BankProfileDAO::updateAppVersion(profile.id, ver)) { - qWarning() << "Cant update app_version for" << profile.code << profile.package; - } + const QString ver = AdbUtils::getAppVersion(scan.adbSerial, profile.package); + if (ver.isEmpty()) continue; // не затираем известную версию пустотой + if (!BankProfileDAO::updateAppVersion(profile.id, ver)) { + 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; } - } else if (status == "device") { + } else { const qint64 now = QDateTime::currentSecsSinceEpoch(); // Основной API обновляем не чаще раза в 60 секунд. if (now - m_lastApiUpdate.value(device.id, 0) >= 60) { device.apiId = saved.apiId; - updateDeviceOnApi(device); - if (!device.image.isEmpty()) { - postScreenshotToApi(saved.apiId, device.image); - } + // PATCH статуса + POST скриншота уходят fire-and-forget внутри. + updateDeviceOnApi(device, device.image); m_lastApiUpdate[device.id] = now; } @@ -473,6 +502,15 @@ void DeviceScreener::start() { for (const QString &id : goneOffline) { m_onlineDevices.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; // Сообщаем серверу что устройство офлайн (отдельно от bank_profile-синка) const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); @@ -584,45 +622,83 @@ QList > DeviceScreener::readAdbDevices() { static const QRegularExpression screenRegex(R"(Physical size: (\d+)x(\d+))"); static const QRegularExpression batteryRegex(R"(level:\s*(\d+))"); -DeviceInfo DeviceScreener::readDeviceInfo(const QString &adbSerial) { - DeviceInfo info; - info.screenWidth = 0; - info.screenHeight = 0; - info.battery = 0; +DeviceScreener::ScanResult DeviceScreener::scanDeviceIo(const QString &adbSerial, + const DeviceInfo &cachedStatic, + bool readApps) { + ScanResult result; + result.adbSerial = adbSerial; - auto runAdbCommand = [&](const QStringList &args) -> QString { + // ok=false означает, что процесс не завершился за таймаут (зависшая команда): + // результат считаем «нет данных», а не валидной пустой строкой, чтобы вызывающий + // код не затирал известные значения. + auto runAdbCommand = [&](const QStringList &args, bool *ok = nullptr) -> QString { QProcess process; process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath()); 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(); }; - const QString androidId = runAdbCommand({"shell", "settings", "get", "secure", "android_id"}); - const QString model = runAdbCommand({"shell", "getprop", "ro.product.model"}); + // Статика (id/name/android/размер экрана) не меняется за сессию. Если передан + // непустой кэш — переиспользуем его и не дёргаем 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; - info.adbSerial = adbSerial; - info.name = model + "-" + info.id; - info.android = runAdbCommand({"shell", "getprop", "ro.build.version.release"}); + // Идентичность не прочиталась (таймаут/пусто) — НЕ подменяем id на adbSerial. + // Возвращаем identified=false; вызывающий код пропустит устройство в этом + // цикле и повторит на следующем. + 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; + } - // Размер экрана - const QString screenOutput = runAdbCommand({"shell", "wm", "size"}); - QRegularExpressionMatch match = screenRegex.match(screenOutput); - if (match.hasMatch()) { - info.screenWidth = match.captured(1).toInt(); - info.screenHeight = match.captured(2).toInt(); + info.id = androidId; + info.adbSerial = adbSerial; + info.name = (modelOk && !model.isEmpty() ? model : QStringLiteral("device")) + "-" + info.id; + info.android = runAdbCommand({"shell", "getprop", "ro.build.version.release"}); + + 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; - // Уровень батареи - const QString batteryOutput = runAdbCommand({"shell", "dumpsys", "battery"}); - match = batteryRegex.match(batteryOutput); - if (match.hasMatch()) { - info.battery = match.captured(1).toInt(); + // Battery — динамическое, читаем каждый цикл. На таймаут/непрочитанное значение + // оставляем прошлое (info уже содержит закэшированное), а не пишем 0. + bool batteryOk = false; + const QString batteryOutput = runAdbCommand({"shell", "dumpsys", "battery"}, &batteryOk); + if (batteryOk) { + if (QRegularExpressionMatch match = batteryRegex.match(batteryOutput); match.hasMatch()) { + info.battery = match.captured(1).toInt(); + } } - 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; } diff --git a/services/DeviceScreener.h b/services/DeviceScreener.h index dfedbb7..783f719 100644 --- a/services/DeviceScreener.h +++ b/services/DeviceScreener.h @@ -17,6 +17,16 @@ public: static constexpr qint64 kOfflineDebounceSecs = 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: void start(); @@ -41,22 +51,43 @@ private: QMap m_offlineSince; // deviceId → когда пропал из ADB (для дебаунса) QMap m_lastMonitoringScreen; // deviceId → unix ts последней проверки кадра для истории экрана QMap m_lastSentScreenHash; // deviceId → sha256 последнего кадра, отправленного в историю экрана + // Кэш неизменных свойств устройства (id/name/android/размер экрана) по adbSerial. + // Эти поля не меняются за сессию, поэтому читаются по adb один раз, а не каждый + // проход цикла. Каждый цикл обновляется только battery. Инвалидируется при уходе + // устройства в OFFLINE (на случай переподключения другого аппарата по тому же serial). + QMap m_staticInfoCache; + QMap 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 apps; // установленные пакеты (валидно только при appsRead) + bool appsRead = false; // true → getListApps вызывался в этом цикле + }; static QList > readAdbDevices(); 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 &devices); QJsonValue apiGet(const QString &path); QJsonValue apiPost(const QString &path, const QJsonObject &body); - void apiPatch(const QString &path, const QJsonObject &body); QString createDeviceOnApi(const DeviceInfo &device); - void updateDeviceOnApi(const DeviceInfo &device); - void postScreenshotToApi(const QString &apiId, const QByteArray &image); + // Строит тело PATCH (с поиском bank_profile_id по БД — вызывать на главном + // потоке) и отправляет статус + опциональный скриншот fire-and-forget через + // dispatchDeviceSyncToApi, чтобы сетевой таймаут не тормозил цикл опроса. + void updateDeviceOnApi(const DeviceInfo &device, const QByteArray &image = {}); // Fire-and-forget отправка кадра истории экрана на сервер логов (monitoringBase). // Создаёт временный NetworkService в отдельном потоке (паттерн LogArchiveSender). @@ -64,4 +95,14 @@ private: const QString &deviceId, const QString &name, 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); };