1
0
forked from BRT/arc
arc/services/DeviceScreener.cpp

593 lines
25 KiB
C++
Raw Permalink 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 <QTimer>
#include <QImage>
#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"
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;
}
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()) {
qWarning() << "createDeviceOnApi: desktop_id не задан";
return {};
}
QJsonObject body;
body["desktop_id"] = desktopId;
body["bank_profile_id"] = QJsonValue::Null;
body["status"] = "ONLINE";
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::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::updateDeviceOnApi(const DeviceInfo &device) {
// Находим первый активный 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();
apiPatch("/api/v1/device/" + device.apiId, body);
}
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() {
while (m_running) {
QList<QPair<QString, QString> > devices = readAdbDevices();
QSet<QString> liveSet; // устройства, видимые как "device" в текущем скане
const qint64 nowSec = QDateTime::currentSecsSinceEpoch();
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;
}
device.status = status;
device.updateTime = QDateTime::currentDateTime();
if (status == "device") {
if (!DeviceDAO::upsertDevice(device)) {
qDebug() << "Cant update device";
}
// Проверяем установленные приложения каждый цикл
QSet<QString> apps = AdbUtils::getListApps(adbSerial);
if (!BankProfileDAO::checkInstalledApps(device.id, apps)) {
qWarning() << "Cant check installed apps";
}
// Обновляем версии установленных банков (не чаще раза в 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;
}
}
}
// Синхронизация с 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 if (status == "device") {
// Обновляем не чаще раза в 60 секунд
const qint64 now = QDateTime::currentSecsSinceEpoch();
if (now - m_lastApiUpdate.value(device.id, 0) >= 60) {
device.apiId = saved.apiId;
updateDeviceOnApi(device);
if (!device.image.isEmpty()) {
postScreenshotToApi(saved.apiId, device.image);
}
m_lastApiUpdate[device.id] = now;
}
}
}
// 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);
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+))");
DeviceInfo DeviceScreener::readDeviceInfo(const QString &adbSerial) {
DeviceInfo info;
info.screenWidth = 0;
info.screenHeight = 0;
info.battery = 0;
auto runAdbCommand = [&](const QStringList &args) -> QString {
QProcess process;
process.setWorkingDirectory(QFileInfo(AdbUtils::adbPath()).absolutePath());
process.start(AdbUtils::adbPath(), QStringList{"-s", adbSerial} + args);
process.waitForFinished();
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"});
info.id = androidId.isEmpty() ? adbSerial : androidId;
info.adbSerial = adbSerial;
info.name = model + "-" + info.id;
info.android = runAdbCommand({"shell", "getprop", "ro.build.version.release"});
// Размер экрана
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();
}
// Уровень батареи
const QString batteryOutput = runAdbCommand({"shell", "dumpsys", "battery"});
match = batteryRegex.match(batteryOutput);
if (match.hasMatch()) {
info.battery = match.captured(1).toInt();
}
info.updateTime = QDateTime::currentDateTime();
return info;
}
void DeviceScreener::stop() {
m_running = false;
}