730 lines
34 KiB
C++
730 lines
34 KiB
C++
#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 <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);
|
||
|
||
while (m_running) {
|
||
QList<QPair<QString, QString> > devices = readAdbDevices();
|
||
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);
|
||
}
|
||
}
|
||
// С каких устройств пора снять превью-кадр (троттл screencap, см.
|
||
// kScreencapIntervalSecs) — чтобы не грузить adb каждый цикл.
|
||
QSet<QString> shotDue;
|
||
for (const QString &serial : deviceSerials) {
|
||
if (nowSec - m_lastScreencap.value(serial, 0) >= kScreencapIntervalSecs) {
|
||
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";
|
||
}
|
||
|
||
// Проверяем установленные приложения по троттлу (список прочитан в фазе 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);
|
||
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) {
|
||
QProcess process;
|
||
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
|
||
QStringList arguments;
|
||
|
||
arguments << "-s" << deviceId << "exec-out" << "screencap" << "-p";
|
||
process.start(AdbUtils::adbPath(), arguments);
|
||
|
||
if (!process.waitForFinished(5000)) {
|
||
qWarning() << "ADB скриншот не завершился для" << deviceId;
|
||
process.kill();
|
||
process.waitForFinished(1000);
|
||
return {};
|
||
}
|
||
|
||
const QByteArray imageData = process.readAllStandardOutput();
|
||
|
||
if (imageData.isEmpty()) {
|
||
qWarning() << "Скриншот пустой для " << deviceId;
|
||
return {};
|
||
}
|
||
|
||
return convertPngToJpeg(imageData, 90);
|
||
}
|
||
|
||
QList<QPair<QString, QString> > DeviceScreener::readAdbDevices() {
|
||
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 {};
|
||
}
|
||
|
||
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) -> 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;
|
||
}
|