#include "DeviceScreener.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "adb/AdbUtils.h" #include "dao/BankProfileDAO.h" #include "dao/MaterialDAO.h" #include "dao/SettingsDAO.h" #include "net/NetworkErrorUtils.h" #include "net/NetworkService.h" DeviceScreener::DeviceScreener(QObject *parent) : QObject(parent) { const QSettings settings("config.ini", QSettings::IniFormat); m_urlAppUpdate = settings.value("net/appInfoUpdate").toString(); m_token = settings.value("common/token").toString(); m_apiBase = settings.value("net/apiBase").toString(); } DeviceScreener::~DeviceScreener() = default; void DeviceScreener::postDevicesData(const QList &devices) { auto *multiPart = new QHttpMultiPart(QHttpMultiPart::FormDataType); QHttpPart jsonPart; jsonPart.setHeader(QNetworkRequest::ContentTypeHeader, "application/json; charset=UTF-8"); jsonPart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="metadata")"); QJsonArray list; for (const DeviceInfo &device: devices) { QJsonObject obj; obj["uniqueName"] = device.id; obj["status"] = device.status; obj["name"] = device.name; obj["screenWidth"] = device.screenWidth; obj["screenHeight"] = device.screenHeight; obj["battery"] = device.battery; list.append(obj); } jsonPart.setBody(QJsonDocument(list).toJson()); multiPart->append(jsonPart); for (const DeviceInfo &device: devices) { QHttpPart imagePart; QString imageHeader = QStringLiteral("form-data; name=files; filename=%1.jpg").arg(device.id); imagePart.setHeader(QNetworkRequest::ContentTypeHeader, "image/jpeg"); imagePart.setHeader(QNetworkRequest::ContentDispositionHeader, imageHeader.toUtf8()); imagePart.setBody(device.image); multiPart->append(imagePart); } QHttpPart uniquePart; uniquePart.setHeader(QNetworkRequest::ContentDispositionHeader, R"(form-data; name="token")"); uniquePart.setBody(m_token.toUtf8()); multiPart->append(uniquePart); QNetworkAccessManager manager; // создаём внутри потока QUrl url(m_urlAppUpdate); QNetworkRequest request(url); // делаем запрос QNetworkReply *reply = manager.post(request, multiPart); multiPart->setParent(reply); // multiPart удалится, когда придёт ответ // локальный цикл — ждём, пока придёт finished() 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()) { qWarning() << "Таймаут postDevicesData:" << m_urlAppUpdate; reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { QByteArray raw = reply->readAll(); QJsonParseError err; QJsonDocument doc = QJsonDocument::fromJson(raw, &err); if (err.error == QJsonParseError::NoError && doc.isObject()) { QJsonObject root = doc.object(); if (!root.value("success").toBool()) { qWarning() << root; } } } else { qWarning() << "Response error:" << reply->errorString() << m_urlAppUpdate; } reply->deleteLater(); } QJsonValue DeviceScreener::apiPost(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->post(request, 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(); QJsonValue result; if (!timeout.isActive()) { qCritical().noquote() << "DeviceScreener apiPost timeout:" << NetworkErrorUtils::describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); if (doc.isObject()) { const QJsonObject root = doc.object(); if (root["success"].toBool()) result = root["data"]; else qCritical() << "DeviceScreener apiPost error:" << root; } } else { qCritical().noquote() << "DeviceScreener apiPost error:" << NetworkErrorUtils::describeNetError(reply, path); } reply->deleteLater(); return result; } QString DeviceScreener::createDeviceOnApi(const DeviceInfo &device) { const QString desktopId = SettingsDAO::get("desktop_id"); if (desktopId.isEmpty()) { qWarning() << "createDeviceOnApi: desktop_id не задан"; return {}; } QJsonObject body; body["desktop_id"] = desktopId; body["bank_profile_id"] = QJsonValue::Null; body["status"] = "ONLINE"; body["android_id"] = device.id; body["name"] = device.name; body["screen_width"] = device.screenWidth; body["screen_height"] = device.screenHeight; body["battery"] = device.battery; const QJsonValue result = apiPost("/api/v1/device/", body); if (!result.isNull() && !result.isUndefined()) { return result.toObject()["id"].toString(); } return {}; } void DeviceScreener::dispatchScreenshotToMonitoring(const QString &desktopId, const QString &deviceId, const QString &name, const QByteArray &jpeg) { if (jpeg.isEmpty()) return; // Отдельный поток + свой NetworkService, чтобы таймаут отправки не тормозил // цикл сканера (паттерн LogArchiveSender::dispatch). auto *thread = new QThread; auto *svc = new NetworkService; svc->moveToThread(thread); QObject::connect(thread, &QThread::started, svc, [svc, desktopId, deviceId, name, jpeg]() { svc->postMonitoringScreenshot(desktopId, deviceId, name, jpeg); emit svc->finished(); }); QObject::connect(svc, &NetworkService::finished, thread, &QThread::quit); QObject::connect(svc, &NetworkService::finished, svc, &QObject::deleteLater); QObject::connect(thread, &QThread::finished, thread, &QObject::deleteLater); thread->start(); } 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) { if (!app.bankProfileId.isEmpty() && app.status == "active") { bankProfileId = app.bankProfileId; break; } } QJsonObject body; body["status"] = (device.status == "device") ? "ONLINE" : "OFFLINE"; body["bank_profile_id"] = bankProfileId; body["battery"] = device.battery; body["update_time"] = QDateTime::currentSecsSinceEpoch(); // Сетевую часть — fire-and-forget, чтобы таймаут не тормозил цикл опроса. dispatchDeviceSyncToApi(m_apiBase, SettingsDAO::get("access_token"), device.apiId, body, image); } QJsonValue DeviceScreener::apiGet(const QString &path) { if (!m_manager) m_manager = new QNetworkAccessManager(this); QNetworkRequest request(QUrl(m_apiBase + path)); request.setRawHeader("Authorization", ("Bearer " + SettingsDAO::get("access_token")).toUtf8()); QNetworkReply *reply = m_manager->get(request); 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(); QJsonValue result; if (!timeout.isActive()) { qCritical().noquote() << "DeviceScreener apiGet timeout:" << NetworkErrorUtils::describeClientTimeout(reply, path); reply->abort(); } else if (reply->error() == QNetworkReply::NoError) { const QJsonDocument doc = QJsonDocument::fromJson(reply->readAll()); if (doc.isObject()) { const QJsonObject root = doc.object(); if (root["success"].toBool()) result = root["data"]; else qCritical() << "DeviceScreener apiGet error:" << root; } } else { qCritical().noquote() << "DeviceScreener apiGet error:" << NetworkErrorUtils::describeNetError(reply, path); } reply->deleteLater(); return result; } void DeviceScreener::start() { // Пул для параллельного adb-опроса (фаза A). Лимит kMaxParallelScans, чтобы // одновременные screencap'ы не насыщали USB-шину. QThreadPool scanPool; scanPool.setMaxThreadCount(kMaxParallelScans); // Интервал превью-кадра зависит от режима скриншота: для http (разгрузка через // arc-shot, байты мимо adb) можно чаще, чем для exec-out (стрим через adb). const QSettings shotCfg("config.ini", QSettings::IniFormat); const qint64 shotIntervalSecs = shotCfg.value("screenshot/previewIntervalSecs", kScreencapIntervalSecs).toInt(); const qint64 shotIntervalHttpSecs = shotCfg.value("screenshot/previewIntervalHttpSecs", 2).toInt(); while (m_running) { bool adbOk = true; QList > devices = readAdbDevices(&adbOk); // Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит, // что устройства пропали. Иначе один зависший вызов (напр. из-за медленных // screencap'ов по Wi-Fi) гасит всю ферму разом (mass-offline каскад). // Пропускаем цикл, сохраняя прошлое online/offline-состояние. if (!adbOk) { QThread::msleep(1000); continue; } 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) { 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); } } // С каких устройств пора снять превью-кадр. Интервал зависит от режима: // http (мимо adb) — чаще (shotIntervalHttpSecs), exec-out — реже // (shotIntervalSecs), чтобы не грузить adb. Режим известен только после // первого кадра — до того идём по обычному интервалу. QSet shotDue; for (const QString &serial : deviceSerials) { const qint64 interval = (AdbUtils::screenshotMode(serial) == "http") ? shotIntervalHttpSecs : shotIntervalSecs; if (nowSec - m_lastScreencap.value(serial, 0) >= interval) { shotDue.insert(serial); } } const QList scans = QtConcurrent::blockingMapped >( &scanPool, deviceSerials, [&cacheSnapshot, &appsDue, &shotDue](const QString &serial) { return scanDeviceIo(serial, cacheSnapshot.value(serial), appsDue.contains(serial), shotDue.contains(serial)); }); // --- Фаза 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); // Отмечаем попытку захвата превью (даже если кадр пустой) — чтобы не // дёргать screencap каждый цикл на устройстве, где он подвисает. if (shotDue.contains(scan.adbSerial)) { m_lastScreencap[scan.adbSerial] = nowSec; } 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"; } // Сохраняем Wi-Fi IP телефона в wifi_connections (по android_id) — задел под // авто-реконнект. Резолвим напрямую (независимо от http-скриншота, чтобы IP // был и для exec-out-устройств); resolvePhoneIp кэширует с TTL ~5 мин, так что // adb дёргается редко. Пишем только при изменении. upsert после upsertDevice — // FK wifi_connections → devices(id). if (const QString ip = AdbUtils::resolvePhoneIp(scan.adbSerial); !ip.isEmpty() && m_lastPersistedIp.value(device.id) != ip) { if (WifiConnectionDAO::upsert(device.id, ip, 5555)) { m_lastPersistedIp[device.id] = ip; } } // Проверяем установленные приложения по троттлу (список прочитан в фазе 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; 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; } } // Синхронизация с API DeviceInfo saved = DeviceDAO::getDeviceById(device.id); if (saved.apiId.isEmpty() && !device.name.isEmpty()) { // Устройство ещё не зарегистрировано — создаём const QString apiId = createDeviceOnApi(device); if (!apiId.isEmpty()) { if (!DeviceDAO::updateApiId(device.id, apiId)) { qWarning() << "Failed to save api_id for device:" << device.id; } qDebug() << "Device registered on API:" << device.id << "->" << apiId; } } else { const qint64 now = QDateTime::currentSecsSinceEpoch(); // Основной API обновляем не чаще раза в 60 секунд. if (now - m_lastApiUpdate.value(device.id, 0) >= 60) { device.apiId = saved.apiId; // PATCH статуса + POST скриншота уходят fire-and-forget внутри. updateDeviceOnApi(device, device.image); m_lastApiUpdate[device.id] = now; } // Отправка истории экрана в arc-monitoring отключена по запросу. // Код сохранён закомментированным на случай возврата функционала. // // История экрана на сервере логов: проверяем не чаще раза в // // kMonitoringScreenIntervalSecs и шлём кадр только если экран // // изменился относительно последнего отправленного (дедуп по sha256). // if (!device.image.isEmpty() // && now - m_lastMonitoringScreen.value(device.id, 0) >= kMonitoringScreenIntervalSecs) { // m_lastMonitoringScreen[device.id] = now; // const QByteArray hash = // QCryptographicHash::hash(device.image, QCryptographicHash::Sha256); // if (m_lastSentScreenHash.value(device.id) != hash) { // m_lastSentScreenHash[device.id] = hash; // dispatchScreenshotToMonitoring(SettingsDAO::get("desktop_id"), // device.id, device.name, device.image); // } // } } } // Detect transitions: // 1) Online: устройство есть в liveSet, но ещё не в m_onlineDevices — мгновенно for (const QString &id : liveSet) { if (!m_onlineDevices.contains(id)) { m_onlineDevices.insert(id); m_offlineSince.remove(id); qDebug() << "[DeviceScreener] device went ONLINE:" << id; emit deviceWentOnline(id); } else { // Устройство всё ещё видно — сбрасываем pending offline-дебаунс (флап) m_offlineSince.remove(id); } } // 2) Offline: устройство было в m_onlineDevices, но больше не видно // Засекаем m_offlineSince; срабатывает после 30s непрерывного отсутствия. QSet goneOffline; for (const QString &id : m_onlineDevices) { if (liveSet.contains(id)) continue; if (!m_offlineSince.contains(id)) { m_offlineSince[id] = nowSec; qDebug() << "[DeviceScreener] device missing, offline debounce started:" << id; } else if (nowSec - m_offlineSince[id] >= kOfflineDebounceSecs) { goneOffline.insert(id); } } 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); if (!offlineDevice.apiId.isEmpty()) { DeviceInfo offlineCopy = offlineDevice; offlineCopy.status = "offline"; updateDeviceOnApi(offlineCopy); } emit deviceWentOffline(id); } // БД status: считаем онлайном только debounced-онлайн. QStringList debouncedOnlineList; for (const QString &id : m_onlineDevices) debouncedOnlineList << id; if (!DeviceDAO::markDevicesOfflineExcept(debouncedOnlineList)) { qDebug() << "Cant mark devices offline"; } if (m_firstScan) { m_firstScan = false; qDebug() << "[DeviceScreener] first pass done, online devices:" << m_onlineDevices; emit firstPassDone(); } QThread::msleep(1000); } emit finished(); } QByteArray convertPngToJpeg(const QByteArray &pngData, const int quality = 90) { QImage image; if (!image.loadFromData(pngData, "PNG")) { qWarning() << "Не удалось загрузить PNG из байт"; return {}; } QByteArray jpgData; QBuffer buffer(&jpgData); buffer.open(QIODevice::WriteOnly); image.save(&buffer, "JPG", quality); return jpgData; } QByteArray DeviceScreener::takeScreenshot(const QString &deviceId) { // Единая стратегия: http через APK arc-shot (мимо adb) с fallback на exec-out. // Раньше здесь был прямой exec-out screencap — по Wi-Fi он виснет и душит adb // (каскад adb devices timeout → mass-offline). const QByteArray imageData = AdbUtils::captureScreenshotBytes(deviceId); if (imageData.isEmpty()) { qWarning() << "Скриншот пустой для " << deviceId; return {}; } return convertPngToJpeg(imageData, 90); } QList > DeviceScreener::readAdbDevices(bool *adbOk) { if (adbOk) *adbOk = false; QProcess process; process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath()); process.start(AdbUtils::adbPath(), QStringList() << "devices"); if (!process.waitForStarted(5000)) { qWarning() << "[DeviceScreener] Failed to start adb devices:" << process.errorString(); return {}; } if (!process.waitForFinished(10000)) { qWarning() << "[DeviceScreener] adb devices timeout"; process.kill(); process.waitForFinished(1000); return {}; } if (adbOk) *adbOk = true; const QString output = process.readAllStandardOutput(); QList > devices; const QStringList lines = output.split('\n'); for (int i = 1; i < lines.size(); ++i) { QString line = lines.at(i).trimmed(); if (line.isEmpty()) { continue; } /** * device — устройство готово к использованию. * offline — устройство подключено, но не может быть использовано. * unauthorized — устройство не авторизовано для работы с ADB (нужно подтвердить запрос на подключение на самом устройстве). * recovery — устройство в режиме восстановления. */ // Разделяем строку на id и type по табуляции if (QStringList parts = line.split('\t'); parts.size() == 2) { const QString &id = parts.at(0); const QString &status = parts.at(1); devices.append(qMakePair(id, status)); } } return devices; } static const QRegularExpression screenRegex(R"(Physical size: (\d+)x(\d+))"); static const QRegularExpression batteryRegex(R"(level:\s*(\d+))"); DeviceScreener::ScanResult DeviceScreener::scanDeviceIo(const QString &adbSerial, const DeviceInfo &cachedStatic, bool readApps, bool takeShot) { ScanResult result; result.adbSerial = adbSerial; // 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); 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(); }; // Статика (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); // Идентичность не прочиталась (таймаут/пусто) — НЕ подменяем 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; } 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; // 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(); result.info = info; // Тяжёлый чистый adb-I/O — превью-кадр и список приложений по троттлу. // takeShot=false → кадр не снимаем (result.image пустой), фаза B просто не // обновит превью в этом цикле; следующий захват — через kScreencapIntervalSecs. if (takeShot) { result.image = takeScreenshot(adbSerial); } if (readApps) { result.apps = AdbUtils::getListApps(adbSerial); result.appsRead = true; } return result; } void DeviceScreener::stop() { m_running = false; }