1
0
forked from BRT/arc
arc/services/DeviceScreener.cpp
2026-06-17 19:30:24 +07:00

778 lines
38 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#include "DeviceScreener.h"
#include <QSettings>
#include <QProcess>
#include <QFile>
#include <QFileInfo>
#include <QBuffer>
#include <qeventloop.h>
#include <QThread>
#include <QThreadPool>
#include <QtConcurrent>
#include <QTimer>
#include <QImage>
#include <QCryptographicHash>
#include <QHttpMultiPart>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonDocument>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <dao/DeviceDAO.h>
#include <dao/WifiConnectionDAO.h>
#include <db/DeviceInfo.h>
#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<DeviceInfo> &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<BankProfileInfo> 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<QPair<QString, QString> > devices = readAdbDevices(&adbOk);
// Сам `adb devices` не отработал (таймаут/не стартовал) — это НЕ значит,
// что устройства пропали. Иначе один зависший вызов (напр. из-за медленных
// screencap'ов по Wi-Fi) гасит всю ферму разом (mass-offline каскад).
// Пропускаем цикл, сохраняя прошлое online/offline-состояние.
if (!adbOk) {
QThread::msleep(1000);
continue;
}
QSet<QString> 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<QString, DeviceInfo> cacheSnapshot = m_staticInfoCache;
// Какие устройства пора перечитать на список приложений (троттл).
QSet<QString> 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<QString> 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<ScanResult> scans = QtConcurrent::blockingMapped<QList<ScanResult> >(
&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). Только IP —
// НЕ трогаем port/code, которые задаёт онбординг по коду (иначе затрём их).
// updateIp обновляет лишь существующую запись; новые реквизиты создаёт онбординг.
// resolvePhoneIp кэширует с TTL ~5 мин, пишем только при изменении IP.
if (const QString ip = AdbUtils::resolvePhoneIp(scan.adbSerial);
!ip.isEmpty() && m_lastPersistedIp.value(device.id) != ip) {
if (WifiConnectionDAO::updateIp(device.id, ip)) {
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<QString> 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);
// Wi-Fi-устройство (IP:port): рвём мёртвый TCP, чтобы adb убрал его из
// `adb devices` и мы перестали его пинговать; сторож переподключит при
// возврате связи (по записи в wifi_connections).
static const QRegularExpression reWifiSerial(R"(^\d+\.\d+\.\d+\.\d+:\d+$)");
if (reWifiSerial.match(offlineDevice.adbSerial).hasMatch()) {
AdbUtils::adbDisconnect(offlineDevice.adbSerial);
}
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<QPair<QString, QString> > 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<QPair<QString, QString> > 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,
int timeoutMs = kAdbCommandTimeoutMs) -> QString {
QProcess process;
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
process.start(AdbUtils::adbPath(), QStringList{"-s", adbSerial} + args);
if (!process.waitForFinished(timeoutMs)) {
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()) {
// Устройство уже идентифицировано — но проверим, что оно ещё ОТВЕЧАЕТ.
// Иначе при падении Wi-Fi мёртвый TCP долго висит в `adb devices` как "device",
// а мы держали бы устройство «онлайн». Быстрый пинг с коротким таймаутом (3с):
// живое отвечает мгновенно, отвалившееся — таймаут → не считаем живым в этом
// цикле → уйдёт в offline по дебаунсу.
bool aliveOk = false;
runAdbCommand({"shell", "true"}, &aliveOk, 3000);
if (!aliveOk) {
qWarning() << "[DeviceScreener] device not responding (link down?):" << adbSerial;
info.adbSerial = adbSerial;
result.info = info;
result.identified = false;
return result;
}
}
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;
}