diff --git a/android/adb/AdbUtils.cpp b/android/adb/AdbUtils.cpp index 2fe9df7..c53c346 100644 --- a/android/adb/AdbUtils.cpp +++ b/android/adb/AdbUtils.cpp @@ -11,6 +11,8 @@ #include #include #include +#include +#include #include "dump/TaskDumpRecorder.h" @@ -206,6 +208,104 @@ QList > AdbUtils::getListDevices() { return devices; } +bool AdbUtils::adbPair(const QString &ip, int port, const QString &code) { + const QString endpoint = ip + ":" + QString::number(port); + QString out, err; + startProcess(adbPath(), {"pair", endpoint, code}, &out, &err); + const QString combined = out + err; + const bool ok = combined.contains("Successfully paired", Qt::CaseInsensitive); + if (ok) { + qInfo().noquote() << "[AdbUtils] adb pair" << endpoint << "→ ok"; + } else { + qWarning().noquote() << "[AdbUtils] adb pair" << endpoint << "failed:" << combined.trimmed(); + } + return ok; +} + +QString AdbUtils::adbConnect(const QString &ip, int port) { + const QString endpoint = ip + ":" + QString::number(port); + QString out, err; + startProcess(adbPath(), {"connect", endpoint}, &out, &err); + const QString combined = out + err; + // adb connect возвращает exit 0 и при неудаче ("failed to connect"), поэтому + // ориентируемся на текст вывода. + if (combined.contains("connected to", Qt::CaseInsensitive) + || combined.contains("already connected", Qt::CaseInsensitive)) { + qInfo().noquote() << "[AdbUtils] adb connect" << endpoint << "→ ok"; + return endpoint; + } + qWarning().noquote() << "[AdbUtils] adb connect" << endpoint << "failed:" << combined.trimmed(); + return {}; +} + +bool AdbUtils::adbDisconnect(const QString &endpoint) { + QString out, err; + startProcess(adbPath(), {"disconnect", endpoint}, &out, &err); + return true; // best-effort +} + +QString AdbUtils::mdnsFindConnect(const QString &ip) { + QString out; + startProcess(adbPath(), {"mdns", "services"}, &out); + // Строки вида: adb-XXXX-YY._adb-tls-connect._tcp. 192.168.1.50:41555 + static const QRegularExpression reEndpoint(R"((\d+\.\d+\.\d+\.\d+):(\d+))"); + const QStringList lines = out.split('\n'); + for (const QString &line : lines) { + if (!line.contains("_adb-tls-connect")) { + continue; + } + if (const auto m = reEndpoint.match(line); m.hasMatch() && m.captured(1) == ip) { + return m.captured(1) + ":" + m.captured(2); + } + } + return {}; +} + +QString AdbUtils::scanFindConnect(const QString &ip, int portStart, int portEnd) { + if (portStart < 1) portStart = 1; + if (portEnd > 65535) portEnd = 65535; + if (portEnd < portStart) return {}; + + // 1. Параллельно ищем открытые TCP-порты на IP телефона (сырой сокет, без adb). + QList ports; + ports.reserve(portEnd - portStart + 1); + for (int p = portStart; p <= portEnd; ++p) ports.append(p); + + QThreadPool pool; + pool.setMaxThreadCount(256); + const QList openPorts = QtConcurrent::blockingFiltered( + &pool, ports, [&ip](int p) { + QTcpSocket s; + s.connectToHost(ip, static_cast(p)); + return s.waitForConnected(250); + }); + + if (openPorts.isEmpty()) { + qWarning().noquote() << "[AdbUtils] scanFindConnect: no open ports on" << ip; + return {}; + } + qInfo().noquote() << "[AdbUtils] scanFindConnect: open ports on" << ip << ":" << openPorts; + + // 2. На каждый открытый порт пробуем adb connect (порт спаривания откажет, connect- + // порт подключится — pairing уже есть, код не нужен). + for (const int p : openPorts) { + if (const QString ep = adbConnect(ip, p); !ep.isEmpty()) { + return ep; + } + } + return {}; +} + +QString AdbUtils::getAndroidId(const QString &deviceId) { + QString out; + if (!startProcess(adbPath(), + {"-s", deviceId, "shell", "settings", "get", "secure", "android_id"}, &out)) { + return {}; + } + const QString id = out.trimmed(); + return (id == "null" || id.isEmpty()) ? QString() : id; +} + QString AdbUtils::tryToGetRunningAppPid(const QString &deviceId, const QString &packageName) { QProcess process; process.setWorkingDirectory(adbDir()); diff --git a/android/adb/AdbUtils.h b/android/adb/AdbUtils.h index 15babde..3e248d8 100644 --- a/android/adb/AdbUtils.h +++ b/android/adb/AdbUtils.h @@ -13,6 +13,28 @@ public: static QList > getListDevices(); + // --- Wi-Fi подключение (wireless debugging) --- + // Спаривание по коду: `adb pair IP:port code`. true при "Successfully paired". + static bool adbPair(const QString &ip, int port, const QString &code); + // `adb connect IP:port`. Возвращает рабочий "IP:port" при успехе, иначе пусто + // (adb может вернуть exit 0 и при неудаче — судим по тексту вывода). + static QString adbConnect(const QString &ip, int port); + // `adb disconnect ` (best-effort). + static bool adbDisconnect(const QString &endpoint); + // Поиск connect-эндпоинта устройства по IP через `adb mdns services` + // (_adb-tls-connect._tcp). Возвращает "IP:port" или пусто. + static QString mdnsFindConnect(const QString &ip); + + // android_id устройства (`settings get secure android_id`) — стабильная личность. + // Пусто при ошибке/таймауте. + static QString getAndroidId(const QString &deviceId); + + // Fallback-поиск connect-порта, когда он сменился (тоггл/ребут wireless debugging), + // а mDNS в сети не работает: параллельно сканирует IP телефона на открытые порты и + // пробует `adb connect` (pairing сохранён → код не нужен). Возвращает рабочий + // "IP:port" или пусто. Тяжёлая операция — звать только как fallback. + static QString scanFindConnect(const QString &ip, int portStart = 30000, int portEnd = 50000); + static QString tryToGetRunningAppPid(const QString &deviceId, const QString &packageName); static bool tryToStartApplication(const QString &deviceId, const QString &packageName); diff --git a/database/DatabaseManager.cpp b/database/DatabaseManager.cpp index 3fcc171..593f559 100644 --- a/database/DatabaseManager.cpp +++ b/database/DatabaseManager.cpp @@ -260,16 +260,17 @@ void DatabaseManager::initSchema() { ))"); // Wi-Fi реквизиты подключения per-device (связь по android_id = devices.id). - // Задел под авто-реконнект по Wi-Fi: ip+port для adb connect, code — одноразовый - // pairing-код (нужен только для первого adb pair; реконнект идёт по ip:port). + // Авто-реконнект по Wi-Fi: ip+port для adb connect. Pairing-код НЕ храним — он + // одноразовый и для реконнекта не нужен (pairing сохраняется на устройстве). q.exec(R"(CREATE TABLE IF NOT EXISTS wifi_connections ( android_id TEXT PRIMARY KEY, ip TEXT, port INTEGER, - code TEXT, updated_at DATETIME, FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE ))"); + // Миграция: убираем колонку code из старых БД (ошибка = колонки уже нет — норм). + q.exec("ALTER TABLE wifi_connections DROP COLUMN code"); db.commit(); } diff --git a/database/dao/WifiConnectionDAO.cpp b/database/dao/WifiConnectionDAO.cpp index c05d42c..d48700a 100644 --- a/database/dao/WifiConnectionDAO.cpp +++ b/database/dao/WifiConnectionDAO.cpp @@ -13,28 +13,24 @@ WifiConnection parseRow(const QSqlQuery &query) { wc.androidId = query.value("android_id").toString(); wc.ip = query.value("ip").toString(); wc.port = query.value("port").toInt(); - wc.code = query.value("code").toString(); wc.updatedAt = query.value("updated_at").toString(); return wc; } } // namespace -bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port, - const QString &code) { +bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int port) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare(R"( - INSERT INTO wifi_connections (android_id, ip, port, code, updated_at) - VALUES (:android_id, :ip, :port, :code, :updated_at) + INSERT INTO wifi_connections (android_id, ip, port, updated_at) + VALUES (:android_id, :ip, :port, :updated_at) ON CONFLICT(android_id) DO UPDATE SET ip = excluded.ip, port = excluded.port, - code = excluded.code, updated_at = excluded.updated_at )"); query.bindValue(":android_id", androidId); query.bindValue(":ip", ip); query.bindValue(":port", port); - query.bindValue(":code", code); query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); if (!query.exec()) { qWarning() << "WifiConnectionDAO::upsert error:" << query.lastError().text(); @@ -43,6 +39,22 @@ bool WifiConnectionDAO::upsert(const QString &androidId, const QString &ip, int return true; } +bool WifiConnectionDAO::updateIp(const QString &androidId, const QString &ip) { + QSqlQuery query(DatabaseManager::instance().database()); + query.prepare(R"( + UPDATE wifi_connections SET ip = :ip, updated_at = :updated_at + WHERE android_id = :android_id + )"); + query.bindValue(":ip", ip); + query.bindValue(":updated_at", QDateTime::currentDateTimeUtc().toString(Qt::ISODate)); + query.bindValue(":android_id", androidId); + if (!query.exec()) { + qWarning() << "WifiConnectionDAO::updateIp error:" << query.lastError().text(); + return false; + } + return true; +} + WifiConnection WifiConnectionDAO::getByAndroidId(const QString &androidId) { QSqlQuery query(DatabaseManager::instance().database()); query.prepare("SELECT * FROM wifi_connections WHERE android_id = :android_id LIMIT 1"); diff --git a/database/dao/WifiConnectionDAO.h b/database/dao/WifiConnectionDAO.h index 57aafbd..090df90 100644 --- a/database/dao/WifiConnectionDAO.h +++ b/database/dao/WifiConnectionDAO.h @@ -8,8 +8,7 @@ struct WifiConnection { QString androidId; // = devices.id QString ip; // последний известный IP телефона в Wi-Fi - int port = 0; // порт для adb connect (5555 для legacy tcpip) - QString code; // одноразовый pairing-код (пусто для legacy tcpip) + int port = 0; // порт для adb connect QString updatedAt; // ISO-время последнего обновления }; @@ -18,9 +17,13 @@ struct WifiConnection { // поиска. В текущей итерации — только хранилище (воркер реконнекта отложен). class WifiConnectionDAO { public: - // Вставка/обновление по android_id (PRIMARY KEY). - static bool upsert(const QString &androidId, const QString &ip, int port, - const QString &code = {}); + // Вставка/обновление по android_id (PRIMARY KEY). Pairing-код НЕ храним: он + // одноразовый и для реконнекта не нужен (pairing сохраняется на устройстве). + static bool upsert(const QString &androidId, const QString &ip, int port); + + // Обновить ТОЛЬКО ip (и updated_at) существующей записи, не трогая port/code. + // Если записи нет — ничего не делает (порт/код задаёт онбординг, не фоновый IP-апдейт). + static bool updateIp(const QString &androidId, const QString &ip); // Запись по android_id (пустой androidId в результате → не найдено). [[nodiscard]] static WifiConnection getByAndroidId(const QString &androidId); diff --git a/images/usb.png b/images/usb.png new file mode 100644 index 0000000..3129a42 Binary files /dev/null and b/images/usb.png differ diff --git a/images/wifi.png b/images/wifi.png new file mode 100644 index 0000000..dff343e Binary files /dev/null and b/images/wifi.png differ diff --git a/main.cpp b/main.cpp index 8211901..2eb4443 100644 --- a/main.cpp +++ b/main.cpp @@ -56,6 +56,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS *ep) { #include "dao/GeneralLogDAO.h" #include "dao/SettingsDAO.h" #include "EventHandler.h" +#include "wifi/WifiAdbConnector.h" #include "net/NetworkService.h" #include "update/ApplyUpdate.h" #include "black/PayByCardScript.h" @@ -126,6 +127,20 @@ void startEventHandler(const QCoreApplication &app) { thread->start(); } +void startWifiAdbConnector(const QCoreApplication &app) { + auto *thread = new QThread; + auto *connector = new WifiAdbConnector; + connector->moveToThread(thread); + QObject::connect(thread, &QThread::started, connector, &WifiAdbConnector::start); + QObject::connect(connector, &WifiAdbConnector::finished, thread, &QThread::quit); + QObject::connect(connector, &WifiAdbConnector::finished, connector, &QObject::deleteLater); + QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); + QObject::connect(&app, &QCoreApplication::aboutToQuit, [=]() { + connector->stop(); + }); + thread->start(); +} + void startPayByPhoneTest() { // Берём первый девайс с ozon аккаунтом QList accounts = MaterialDAO::getAccountsByAppCode("ozon"); @@ -439,6 +454,7 @@ int main(int argc, char *argv[]) { Qt::QueuedConnection); startDeviceScreener(app, net); startEventHandler(app); + startWifiAdbConnector(app); // startBlackPayByCardTest(); // Авторизация прошла — шлём лог прошлой сессии в арк-мониторинг. LogArchiveSender::dispatch("STARTUP", -1, {}, prevLogPath); @@ -476,6 +492,7 @@ int main(int argc, char *argv[]) { Qt::QueuedConnection); startDeviceScreener(app, net); startEventHandler(app); + startWifiAdbConnector(app); // startBlackPayByCardTest(); // Авторизация прошла — шлём лог прошлой сессии в арк-мониторинг. LogArchiveSender::dispatch("STARTUP", -1, {}, prevLogPath); @@ -503,6 +520,7 @@ int main(int argc, char *argv[]) { Qt::QueuedConnection); startDeviceScreener(app, net); startEventHandler(app); + startWifiAdbConnector(app); // startBlackPayByCardTest(); // Авторизация прошла — шлём лог прошлой сессии в арк-мониторинг. LogArchiveSender::dispatch("STARTUP", -1, {}, prevLogPath); diff --git a/migrations.sql b/migrations.sql index 4cf3278..2fcb0c5 100644 --- a/migrations.sql +++ b/migrations.sql @@ -147,13 +147,12 @@ CREATE TABLE IF NOT EXISTS settings ); -- Wi-Fi реквизиты подключения per-device (связь по android_id = devices.id). --- ip+port — для adb connect; code — одноразовый pairing-код (только для первого adb pair). +-- ip+port — для adb connect. Pairing-код не храним (одноразовый, для реконнекта не нужен). CREATE TABLE IF NOT EXISTS wifi_connections ( android_id TEXT PRIMARY KEY, ip TEXT, port INTEGER, - code TEXT, updated_at DATETIME, FOREIGN KEY (android_id) REFERENCES devices (id) ON DELETE CASCADE ); diff --git a/services/DeviceScreener.cpp b/services/DeviceScreener.cpp index b627c85..0f1f8c3 100644 --- a/services/DeviceScreener.cpp +++ b/services/DeviceScreener.cpp @@ -447,14 +447,13 @@ void DeviceScreener::start() { qDebug() << "Cant update device"; } - // Сохраняем Wi-Fi IP телефона в wifi_connections (по android_id) — задел под - // авто-реконнект. Резолвим напрямую (независимо от http-скриншота, чтобы IP - // был и для exec-out-устройств); resolvePhoneIp кэширует с TTL ~5 мин, так что - // adb дёргается редко. Пишем только при изменении. upsert после upsertDevice — - // FK wifi_connections → devices(id). + // Обновляем 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::upsert(device.id, ip, 5555)) { + if (WifiConnectionDAO::updateIp(device.id, ip)) { m_lastPersistedIp[device.id] = ip; } } @@ -566,6 +565,13 @@ void DeviceScreener::start() { 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"; @@ -674,11 +680,12 @@ DeviceScreener::ScanResult DeviceScreener::scanDeviceIo(const QString &adbSerial // ok=false означает, что процесс не завершился за таймаут (зависшая команда): // результат считаем «нет данных», а не валидной пустой строкой, чтобы вызывающий // код не затирал известные значения. - auto runAdbCommand = [&](const QStringList &args, bool *ok = nullptr) -> QString { + 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(kAdbCommandTimeoutMs)) { + if (!process.waitForFinished(timeoutMs)) { qWarning() << "[DeviceScreener] adb command timeout for" << adbSerial << args; process.kill(); process.waitForFinished(1000); @@ -692,6 +699,22 @@ DeviceScreener::ScanResult DeviceScreener::scanDeviceIo(const QString &adbSerial // Статика (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; diff --git a/services/wifi/WifiAdbConnector.cpp b/services/wifi/WifiAdbConnector.cpp new file mode 100644 index 0000000..04c4f63 --- /dev/null +++ b/services/wifi/WifiAdbConnector.cpp @@ -0,0 +1,87 @@ +#include "WifiAdbConnector.h" + +#include +#include +#include +#include + +#include "adb/AdbUtils.h" +#include "dao/WifiConnectionDAO.h" + +WifiAdbConnector::WifiAdbConnector(QObject *parent) : QObject(parent) { + const QSettings settings("config.ini", QSettings::IniFormat); + m_intervalSecs = settings.value("wifi_adb/reconnectIntervalSecs", 30).toInt(); + if (m_intervalSecs < 5) m_intervalSecs = 5; +} + +void WifiAdbConnector::start() { + qDebug() << "[WifiAdbConnector] started, interval" << m_intervalSecs << "s"; + while (m_running) { + reconnectCycle(); + // Спим интервал посекундно, чтобы stop() срабатывал быстро. + for (int i = 0; i < m_intervalSecs && m_running; ++i) { + QThread::msleep(1000); + } + } + emit finished(); +} + +void WifiAdbConnector::stop() { + m_running = false; +} + +void WifiAdbConnector::reconnectCycle() { + const QList records = WifiConnectionDAO::getAll(); + if (records.isEmpty()) { + return; + } + + // Текущее состояние adb: endpoint → status. + QHash status; + for (const auto &[serial, st] : AdbUtils::getListDevices()) { + status.insert(serial, st); + } + + for (const WifiConnection &wc : records) { + if (!m_running) break; + if (wc.ip.isEmpty() || wc.port <= 0) continue; + + const QString endpoint = wc.ip + ":" + QString::number(wc.port); + const QString st = status.value(endpoint); + if (st == "device") { + continue; // живо — не трогаем (не рвём связь работающего устройства) + } + if (st == "offline") { + // Частый случай при слабом Wi-Fi — пересоздаём соединение. + AdbUtils::adbDisconnect(endpoint); + } + + // Пытаемся подключиться по сохранённому порту. + QString ep = AdbUtils::adbConnect(wc.ip, wc.port); + if (ep.isEmpty()) { + // Порт мог смениться (тоггл/ребут wireless debugging). Сначала mDNS… + if (const QString mep = AdbUtils::mdnsFindConnect(wc.ip); !mep.isEmpty()) { + const int c = mep.lastIndexOf(':'); + ep = AdbUtils::adbConnect(mep.left(c), mep.mid(c + 1).toInt()); + } + } + if (ep.isEmpty()) { + // …mDNS пуст (сеть режет мультикаст) — скан-fallback: ищем новый порт + // перебором (pairing сохранён, код не нужен). Тяжело, но только для + // реально отвалившихся устройств. + ep = AdbUtils::scanFindConnect(wc.ip); + } + if (ep.isEmpty()) { + continue; // не нашли — попробуем в следующем цикле + } + // Подключились. Если эндпоинт сменился — обновляем реестр (порт), сохраняя код. + const int c = ep.lastIndexOf(':'); + const QString newIp = ep.left(c); + const int newPort = ep.mid(c + 1).toInt(); + if (newIp != wc.ip || newPort != wc.port) { + WifiConnectionDAO::upsert(wc.androidId, newIp, newPort); + qDebug() << "[WifiAdbConnector] endpoint updated for" << wc.androidId + << "→" << ep; + } + } +} diff --git a/services/wifi/WifiAdbConnector.h b/services/wifi/WifiAdbConnector.h new file mode 100644 index 0000000..1b05205 --- /dev/null +++ b/services/wifi/WifiAdbConnector.h @@ -0,0 +1,28 @@ +#pragma once + +#include + +// Сторож Wi-Fi-подключений: фоновый воркер (QThread), который держит устройства из +// реестра wifi_connections на связи. Раз в ~intervalSecs проходит по записям и +// переподключает отвалившихся (offline → disconnect+connect, absent → connect, при +// смене порта — поиск через mDNS). Повторный pair не нужен — хост уже доверенный. +// Само устройство подхватывает DeviceScreener (общий adb-сервер). +class WifiAdbConnector final : public QObject { + Q_OBJECT + +public: + explicit WifiAdbConnector(QObject *parent = nullptr); + +public slots: + void start(); + void stop(); + +signals: + void finished(); + +private: + void reconnectCycle(); + + bool m_running = true; + int m_intervalSecs = 30; +}; diff --git a/views/MainWindow.cpp b/views/MainWindow.cpp index 7498c80..0cfc907 100644 --- a/views/MainWindow.cpp +++ b/views/MainWindow.cpp @@ -21,6 +21,7 @@ #include "net/NetworkService.h" #include "db/DeviceInfo.h" #include "device/DeviceWidget.h" +#include "device/WifiConnectDialog.h" #include "dao/BankProfileDAO.h" #include "DatabaseManager.h" #include "dao/DeviceDAO.h" @@ -70,6 +71,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { auto *tutorialPageAction = new QAction("Инструкция", this); auto *eventAction = new QAction("События", this); auto *fileLogAction = new QAction("Логи", this); + auto *wifiConnectAction = new QAction("Подключить по Wi-Fi", this); auto *updateAction = new QAction("Проверить обновления", this); m_updateAction = updateAction; auto *exitAction = new QAction("Выйти", this); @@ -78,6 +80,7 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { menu->addAction(eventAction); menu->addAction(tutorialPageAction); menu->addAction(fileLogAction); + menu->addAction(wifiConnectAction); menu->addAction(updateAction); menu->addAction(exitAction); @@ -126,6 +129,12 @@ MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { } }); + connect(wifiConnectAction, &QAction::triggered, this, [this]() { + auto *dlg = new WifiConnectDialog(this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->show(); + }); + connect(updateAction, &QAction::triggered, this, &MainWindow::checkForUpdates); connect(exitAction, &QAction::triggered, this, [this]() { diff --git a/views/device/DeviceWidget.cpp b/views/device/DeviceWidget.cpp index d574e79..3f37973 100644 --- a/views/device/DeviceWidget.cpp +++ b/views/device/DeviceWidget.cpp @@ -359,7 +359,31 @@ DeviceWidget::DeviceWidget(const DeviceInfo &device, QWidget *parent): QWidget(p "color: white;" "font-size: 10px;" ); - layout->addWidget(statusLabel, 0, 0, Qt::AlignTop | Qt::AlignLeft); + + // Имя устройства + иконка типа подключения (USB/Wi-Fi) под ним. Тип — по формату + // adb_serial: IP:port → Wi-Fi, иначе USB. + auto *topLeft = new QWidget(this); + topLeft->setStyleSheet("background: transparent;"); + auto *topLeftLayout = new QVBoxLayout(topLeft); + topLeftLayout->setContentsMargins(0, 0, 0, 0); + topLeftLayout->setSpacing(2); + topLeftLayout->addWidget(statusLabel); + + if (device.status == "device" && !device.adbSerial.isEmpty()) { + // Wi-Fi-серийник: IP:port (ручной adb connect) ИЛИ mDNS-имя wireless debugging + // (adb-XXXX._adb-tls-connect._tcp). USB-серийник — обычный hex, без ':' и '_tcp'. + static const QRegularExpression reIpPort(R"(^\d+\.\d+\.\d+\.\d+:\d+$)"); + const bool isWifi = reIpPort.match(device.adbSerial).hasMatch() + || device.adbSerial.contains("_tcp", Qt::CaseInsensitive); + auto *connIcon = new QLabel(topLeft); + const QPixmap p(QString("%1/images/%2.png") + .arg(QCoreApplication::applicationDirPath(), isWifi ? "wifi" : "usb")); + connIcon->setPixmap(p.scaled(18, 18, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + connIcon->setStyleSheet("background: transparent;"); + connIcon->setToolTip(isWifi ? "Wi-Fi" : "USB"); + topLeftLayout->addWidget(connIcon, 0, Qt::AlignLeft); + } + layout->addWidget(topLeft, 0, 0, Qt::AlignTop | Qt::AlignLeft); // Кнопки настроек и удаления (справа сверху) auto *topRightWidget = new QWidget(this); diff --git a/views/device/WifiConnectDialog.cpp b/views/device/WifiConnectDialog.cpp new file mode 100644 index 0000000..1be6cc2 --- /dev/null +++ b/views/device/WifiConnectDialog.cpp @@ -0,0 +1,136 @@ +#include "WifiConnectDialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "adb/AdbUtils.h" +#include "dao/WifiConnectionDAO.h" + +WifiConnectDialog::WifiConnectDialog(QWidget *parent) : QDialog(parent) { + setWindowTitle("Подключить по Wi-Fi"); + setModal(true); + + auto *form = new QFormLayout; + m_ip = new QLineEdit(this); + m_ip->setPlaceholderText("192.168.1.50"); + m_port = new QLineEdit(this); + m_port->setPlaceholderText("37123"); + m_port->setValidator(new QIntValidator(1, 65535, this)); + m_code = new QLineEdit(this); + m_code->setPlaceholderText("6-значный код"); + + form->addRow("IP", m_ip); + form->addRow("Порт", m_port); + form->addRow("Код", m_code); + + m_btn = new QPushButton("Подключить", this); + connect(m_btn, &QPushButton::clicked, this, &WifiConnectDialog::onConnect); + + m_status = new QLabel(this); + m_status->setWordWrap(true); + m_status->setStyleSheet("color: #555; font-size: 11px;"); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(form); + layout->addWidget(m_btn); + layout->addWidget(m_status); + + // Подсказка оператору: где взять реквизиты. + auto *hint = new QLabel( + "Телефон → Для разработчиков → Беспроводная отладка →\n" + "«Подключение с помощью кода»: введите показанные IP:порт и код.", this); + hint->setWordWrap(true); + hint->setStyleSheet("color: #888; font-size: 10px;"); + layout->addWidget(hint); +} + +void WifiConnectDialog::setStatus(const QString &text) { + if (m_status) m_status->setText(text); +} + +void WifiConnectDialog::onConnect() { + const QString ip = m_ip->text().trimmed(); + const int port = m_port->text().trimmed().toInt(); + const QString code = m_code->text().trimmed(); + if (ip.isEmpty() || port <= 0 || code.isEmpty()) { + setStatus("Заполните IP, порт и код."); + return; + } + + m_btn->setEnabled(false); + setStatus("Спаривание и подключение…"); + + // Лоадер на время онбординга (бесконечный индикатор, без кнопки отмены). + auto *progress = new QProgressDialog("Спаривание и подключение…", QString(), 0, 0, this); + progress->setWindowTitle("Подключение по Wi-Fi"); + progress->setWindowModality(Qt::WindowModal); + progress->setCancelButton(nullptr); + progress->setMinimumDuration(0); + progress->setAutoClose(false); + progress->setAutoReset(false); + progress->setWindowFlags(progress->windowFlags() + & ~Qt::WindowCloseButtonHint & ~Qt::WindowContextHelpButtonHint); + progress->show(); + + // Результат онбординга, заполняется в фоновом потоке. + struct Res { bool ok = false; QString msg; }; + auto *res = new Res; + + auto *worker = QThread::create([res, ip, port, code]() { + if (!AdbUtils::adbPair(ip, port, code)) { + res->msg = "Не удалось спарить — проверьте порт и код (код одноразовый, " + "переоткройте экран спаривания на телефоне)."; + return; + } + // Connect: сначала тем же портом, при неудаче — поиск connect-порта через mDNS. + QString endpoint = AdbUtils::adbConnect(ip, port); + if (endpoint.isEmpty()) { + if (const QString mep = AdbUtils::mdnsFindConnect(ip); !mep.isEmpty()) { + const int c = mep.lastIndexOf(':'); + endpoint = AdbUtils::adbConnect(mep.left(c), mep.mid(c + 1).toInt()); + } + } + if (endpoint.isEmpty()) { + res->msg = "Спарено, но не удалось подключиться. Проверьте порт подключения " + "(в «Беспроводной отладке» он отличается от порта спаривания)."; + return; + } + const QString androidId = AdbUtils::getAndroidId(endpoint); + if (androidId.isEmpty()) { + res->msg = "Подключено (" + endpoint + "), но не удалось прочитать android_id."; + return; + } + const int connectPort = endpoint.mid(endpoint.lastIndexOf(':') + 1).toInt(); + // Код НЕ сохраняем — он одноразовый, для реконнекта не нужен. + WifiConnectionDAO::upsert(androidId, ip, connectPort); + res->ok = true; + res->msg = "Подключено: " + endpoint + ". Устройство появится в списке."; + }); + + QPointer guard(this); + QPointer progressGuard(progress); + connect(worker, &QThread::finished, qApp, [worker, res, guard, progressGuard]() { + if (progressGuard) { + progressGuard->reset(); + progressGuard->deleteLater(); + } + if (guard) { + guard->setStatus(res->msg); + guard->m_btn->setEnabled(true); + if (res->ok) { + guard->m_code->clear(); // код одноразовый — очистим для следующего ввода + } + } + delete res; + worker->deleteLater(); + }); + worker->start(); +} diff --git a/views/device/WifiConnectDialog.h b/views/device/WifiConnectDialog.h new file mode 100644 index 0000000..75f8be7 --- /dev/null +++ b/views/device/WifiConnectDialog.h @@ -0,0 +1,27 @@ +#pragma once + +#include + +class QLineEdit; +class QPushButton; +class QLabel; + +// Диалог «Подключить по Wi-Fi»: оператор вводит IP, порт и pairing-код. +// По кнопке — онбординг в фоновом потоке: adb pair → adb connect → читаем android_id → +// запись в wifi_connections. Дальше устройство подхватывает DeviceScreener. +class WifiConnectDialog final : public QDialog { + Q_OBJECT + +public: + explicit WifiConnectDialog(QWidget *parent = nullptr); + +private: + void onConnect(); + void setStatus(const QString &text); + + QLineEdit *m_ip = nullptr; + QLineEdit *m_port = nullptr; + QLineEdit *m_code = nullptr; + QPushButton *m_btn = nullptr; + QLabel *m_status = nullptr; +};