#include "DeviceScreener.h" #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" 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() << "DeviceScreener apiPost timeout:" << 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() << "DeviceScreener apiPost network error:" << reply->errorString(); } 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() << "DeviceScreener apiPatch timeout:" << path; reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { qCritical() << "DeviceScreener apiPatch network error:" << reply->errorString(); } 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() << "postScreenshotToApi timeout for device" << apiId; reply->abort(); } else if (reply->error() != QNetworkReply::NoError) { qWarning() << "postScreenshotToApi network error:" << reply->errorString(); } 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 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); } void DeviceScreener::toggleBankProfilesOnApi(const QString &deviceId, bool enable) { if (enable) return; // не включаем автоматически при появлении устройства const QList apps = BankProfileDAO::getAllApplicationsByDeviceId(deviceId); for (const BankProfileInfo &app : apps) { if (app.bankProfileId.isEmpty()) continue; if (app.status != "active") continue; apiPatch("/api/v1/profile/bank_profile/" + app.bankProfileId + "/false", QJsonObject()); const QList materials = MaterialDAO::getAccounts(deviceId, app.code); for (const MaterialInfo &mat : materials) { if (mat.materialId.isEmpty()) continue; apiPatch("/api/v1/profile/material/" + mat.materialId + "/false", QJsonObject()); } } BankProfileDAO::deactivateAppsForDevice(deviceId); } 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() << "DeviceScreener apiGet timeout:" << 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() << "DeviceScreener apiGet network error:" << reply->errorString(); } reply->deleteLater(); return result; } void DeviceScreener::start() { while (m_running) { // Устройства, которые были онлайн в БД до текущего скана const QStringList prevOnline = DeviceDAO::getOnlineDeviceCodes(); QList > devices = readAdbDevices(); QStringList onlineIds; QList deviceInfos; for (const auto &[adbSerial, status]: devices) { DeviceInfo device; bool isChecked = true; 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; } } onlineIds << device.id; isChecked = prevOnline.contains(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(); deviceInfos.append(device); if (status == "device") { if (!DeviceDAO::upsertDevice(device)) { qDebug() << "Cant update device"; } // Проверяем установленные приложения каждый цикл QSet apps = AdbUtils::getListApps(adbSerial); if (!BankProfileDAO::checkInstalledApps(device.id, apps)) { qWarning() << "Cant check installed apps"; } } // Синхронизация с API DeviceInfo saved = DeviceDAO::getDeviceById(device.id); if (saved.apiId.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; } } } if (!DeviceDAO::markDevicesOfflineExcept(onlineIds)) { qDebug() << "Cant mark devices offline"; } // Было онлайн в БД, сейчас офлайн — выключаем профили и обновляем статус на сервере for (const QString &id : prevOnline) { if (!onlineIds.contains(id)) { toggleBankProfilesOnApi(id, false); // Отправляем OFFLINE статус на сервер const DeviceInfo offlineDevice = DeviceDAO::getDeviceById(id); if (!offlineDevice.apiId.isEmpty()) { DeviceInfo offlineCopy = offlineDevice; offlineCopy.status = "offline"; updateDeviceOnApi(offlineCopy); } } } if (m_firstScan) { m_firstScan = false; } 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; return {}; } const QByteArray imageData = process.readAllStandardOutput(); if (imageData.isEmpty()) { qWarning() << "Скриншот пустой для " << deviceId; return {}; } return convertPngToJpeg(imageData, 90); } QList > 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 {}; } process.waitForFinished(10000); 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+))"); 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; }